Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
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 warp_core::settings::Setting;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::Expanded;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DispatchEventResult, DropTarget, Element, EventHandler, Flex, Hoverable, MainAxisSize,
|
||||
OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _,
|
||||
};
|
||||
|
||||
pub(super) const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_RADIUS: f32 = 8.;
|
||||
|
||||
const CLOUD_MODE_V2_TOP_ROW_GAP: f32 = 10.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_TOP_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_EDITOR_BOTTOM_PADDING: f32 = 8.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_BOTTOM_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_TOP_ROW_INNER_GAP: f32 = 4.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_MIN_EDITOR_HEIGHT: f32 = 80.;
|
||||
|
||||
/// Horizontal gutter applied symmetrically on both sides of the V2 cloud-mode
|
||||
/// composing UI so the floating input has matching breathing room on the left
|
||||
/// and right at narrow widths.
|
||||
const CLOUD_MODE_V2_HORIZONTAL_GUTTER: f32 = 16.;
|
||||
|
||||
// Top padding above the attachment chips row inside the V2 input container.
|
||||
const CLOUD_MODE_V2_CHIPS_ROW_TOP_PADDING: f32 = 4.;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Renders the input when there is an active `AgentView`.
|
||||
///
|
||||
/// Only used when `FeatureFlag::AgentView` is enabled.
|
||||
pub(super) fn render_agent_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if self.is_cloud_mode_input_v2_composing(app) {
|
||||
return self.render_cloud_mode_v2_composing_input(app);
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, /*is_compact_mode=*/ false)
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI)
|
||||
{
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let show_harness_row = FeatureFlag::CloudMode.is_enabled()
|
||||
&& FeatureFlag::AgentHarness.is_enabled()
|
||||
&& self
|
||||
.ambient_agent_view_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(),
|
||||
);
|
||||
}
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
column.add_child(
|
||||
Container::new(self.render_input_box(/*show_vim_status=*/ false, appearance, app))
|
||||
.with_margin_top(
|
||||
terminal_spacing.prompt_to_editor_padding
|
||||
* spacing::UDI_PROMPT_BOTTOM_PADDING_FACTOR,
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
column.add_child(
|
||||
SavePosition::new(
|
||||
ChildView::new(&self.agent_input_footer).finish(),
|
||||
&self.prompt_save_position_id(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false,
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let border_color = 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
|
||||
// classification is necessarily stale (intentionally inherited from the last
|
||||
// classification prior to clearing the input)
|
||||
&& (!self.editor.as_ref(app).is_empty(app)
|
||||
|| self.ai_input_model.as_ref(app).is_input_type_locked())
|
||||
{
|
||||
appearance.theme().ansi_fg_blue()
|
||||
} else {
|
||||
styles::default_border_color(appearance.theme())
|
||||
};
|
||||
|
||||
let mut input = Container::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_border(Border::top(1.).with_border_color(border_color))
|
||||
.with_padding_bottom(4.);
|
||||
|
||||
if self.agent_view_controller.as_ref(app).is_inline() {
|
||||
input = input.with_background(agent_view_bg_fill(app));
|
||||
}
|
||||
|
||||
let input = input.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_model_selector_view).finish());
|
||||
} else if FeatureFlag::InlineProfileSelector.is_enabled()
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.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() {
|
||||
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());
|
||||
} else if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& self.suggestions_mode_model.as_ref(app).is_skill_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_user_query_menu() {
|
||||
column.add_child(ChildView::new(&self.user_query_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_rewind_menu() {
|
||||
column.add_child(ChildView::new(&self.rewind_menu_view).finish());
|
||||
} else if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_repos_menu() {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_plan_menu() {
|
||||
column.add_child(ChildView::new(&self.inline_plan_menu_view).finish());
|
||||
}
|
||||
|
||||
if self
|
||||
.agent_shortcut_view_model
|
||||
.as_ref(app)
|
||||
.is_shortcut_view_open()
|
||||
{
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let (is_cloud_agent, has_submitted_first_prompt) =
|
||||
match agent_view_controller.agent_view_state() {
|
||||
AgentViewState::Active {
|
||||
conversation_id,
|
||||
origin,
|
||||
..
|
||||
} => {
|
||||
let is_cloud_agent = origin.is_cloud_agent();
|
||||
let has_submitted_first_prompt = if is_cloud_agent {
|
||||
BlocklistAIHistoryModel::as_ref(app)
|
||||
.conversation(conversation_id)
|
||||
.is_some_and(|c| c.initial_user_query().is_some())
|
||||
} else {
|
||||
true
|
||||
};
|
||||
(is_cloud_agent, has_submitted_first_prompt)
|
||||
}
|
||||
// When inactive, show all shortcuts (treat as not-cloud and not in the zero-state).
|
||||
AgentViewState::Inactive => (false, true),
|
||||
};
|
||||
|
||||
column.add_child(render_agent_shortcuts_view(
|
||||
AgentShortcutsViewContext {
|
||||
is_cloud_agent,
|
||||
has_submitted_first_prompt,
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
|
||||
let mut outer_stack = Stack::new().with_constrain_absolute_children();
|
||||
outer_stack.add_child(column.finish());
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut outer_stack,
|
||||
&self.buy_credits_banner,
|
||||
self.is_pane_focused(app),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_composing_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
let model = self.model.lock();
|
||||
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
// Apply the V2 gutter symmetrically (left + right) so the floating
|
||||
// input keeps equal breathing room on both sides as the pane shrinks.
|
||||
// The shared `wrap_input_with_terminal_padding_and_focus_handler`
|
||||
// helper only pads the left, so V2 inlines its own padding + focus
|
||||
// handler instead of routing through it.
|
||||
let centered_content = Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Expanded::new(1., self.render_cloud_mode_v2_content(appearance, app)).finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(CLOUD_MODE_V2_HORIZONTAL_GUTTER)
|
||||
.with_padding_right(CLOUD_MODE_V2_HORIZONTAL_GUTTER)
|
||||
.finish();
|
||||
|
||||
let centered_content = if self.is_active_session(app) {
|
||||
EventHandler::new(centered_content)
|
||||
.on_left_mouse_down(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
|
||||
ctx.dispatch_typed_action(InputAction::FocusInputBox);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
centered_content
|
||||
};
|
||||
|
||||
stack.add_child(centered_content);
|
||||
|
||||
if let Some(history_menu) = self.render_cloud_mode_v2_history_menu(app) {
|
||||
let prompt_position = self.prompt_save_position_id();
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(history_menu)
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
.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(-CLOUD_MODE_V2_TOP_ROW_GAP),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut outer_stack = Stack::new().with_constrain_absolute_children();
|
||||
outer_stack.add_child(input);
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut outer_stack,
|
||||
&self.buy_credits_banner,
|
||||
self.is_pane_focused(app),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.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));
|
||||
Align::new(
|
||||
ConstrainedBox::new(column.finish())
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_history_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if !self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let view = self.cloud_mode_v2_history_menu_view.as_ref()?;
|
||||
Some(ChildView::new(view).finish())
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_top_row(&self) -> 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());
|
||||
}
|
||||
row.add_child(ChildView::new(&self.harness_selector).finish());
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_input_container(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background = internal_colors::fg_overlay_1(theme);
|
||||
let border_color = internal_colors::neutral_2(theme);
|
||||
|
||||
let editor_with_min_height =
|
||||
ConstrainedBox::new(self.render_input_box(/*show_vim_status=*/ false, appearance, app))
|
||||
.with_min_height(CLOUD_MODE_V2_INPUT_MIN_EDITOR_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let mut editor_column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
let show_chips = FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI);
|
||||
if show_chips {
|
||||
if let Some(chips) = self.render_attachment_chips(appearance) {
|
||||
editor_column.add_child(
|
||||
Container::new(chips)
|
||||
.with_padding_top(CLOUD_MODE_V2_CHIPS_ROW_TOP_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
editor_column.add_child(
|
||||
Container::new(editor_with_min_height)
|
||||
.with_padding_top(CLOUD_MODE_V2_INPUT_TOP_PADDING)
|
||||
.with_padding_bottom(CLOUD_MODE_V2_INPUT_EDITOR_BOTTOM_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let editor = editor_column.finish();
|
||||
|
||||
let footer = Container::new(ChildView::new(&self.agent_input_footer).finish())
|
||||
.with_padding_bottom(CLOUD_MODE_V2_INPUT_BOTTOM_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish();
|
||||
|
||||
let stacked = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(editor)
|
||||
.with_child(footer)
|
||||
.finish();
|
||||
|
||||
Container::new(SavePosition::new(stacked, &self.prompt_save_position_id()).finish())
|
||||
.with_background(background)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
CLOUD_MODE_V2_INPUT_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
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 mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
// Don't render status bar when agent has failed or is waiting for session
|
||||
let show_status_bar = ambient_agent_model.error_message().is_none()
|
||||
&& !ambient_agent_model.is_waiting_for_session();
|
||||
|
||||
let model = self.model.lock();
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut stack,
|
||||
&self.buy_credits_banner,
|
||||
self.focus_handle.as_ref().is_none_or(|h| h.is_focused(app)),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
let save_position =
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish();
|
||||
|
||||
let input = Hoverable::new(self.hoverable_handle.clone(), |_| save_position)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
if show_status_bar {
|
||||
column.add_child(ChildView::new(&self.agent_status_view).finish());
|
||||
}
|
||||
column.add_child(input);
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn default_border_color(theme: &WarpTheme) -> ColorU {
|
||||
blended_colors::neutral_2(theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use warpui::{Entity, ModelContext, ViewHandle};
|
||||
|
||||
use crate::editor::{self, EditorView, Point};
|
||||
|
||||
/// A 'replica' of the input buffer's contents that may be subscribed to without
|
||||
/// a strict dependency on the Input view itself.
|
||||
pub struct InputBufferModel {
|
||||
buffer_value: String,
|
||||
cursor_point: Option<Point>,
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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(..)
|
||||
| editor::Event::CtrlC { .. }
|
||||
| editor::Event::BufferReinitialized
|
||||
| editor::Event::SelectionChanged
|
||||
| editor::Event::BufferReplaced => {
|
||||
if let Some(editor) = editor_clone.upgrade(ctx) {
|
||||
let editor_ref = editor.as_ref(ctx);
|
||||
let mut value = editor_ref.buffer_text(ctx);
|
||||
let cursor_point = editor_ref.single_cursor_to_point(ctx);
|
||||
|
||||
me.cursor_point = cursor_point;
|
||||
if value != me.buffer_value {
|
||||
std::mem::swap(&mut value, &mut me.buffer_value);
|
||||
ctx.emit(InputBufferUpdateEvent {
|
||||
old_content: value,
|
||||
new_content: me.buffer_value.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
let editor_ref = editor.as_ref(ctx);
|
||||
Self {
|
||||
buffer_value: editor_ref.buffer_text(ctx),
|
||||
cursor_point: editor_ref.single_cursor_to_point(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_value(&self) -> &str {
|
||||
self.buffer_value.as_str()
|
||||
}
|
||||
|
||||
pub fn cursor_point(&self) -> Option<Point> {
|
||||
self.cursor_point
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputBufferUpdateEvent {
|
||||
pub old_content: String,
|
||||
pub new_content: String,
|
||||
}
|
||||
|
||||
impl Entity for InputBufferModel {
|
||||
type Event = InputBufferUpdateEvent;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
|
||||
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
SavePosition, Stack,
|
||||
},
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use super::{should_render_prompt_using_editor_decorator_elements, Input, SubshellRenderState};
|
||||
|
||||
impl Input {
|
||||
/// Renders the classic input. This is used when the user has 'Honor PS1' enabled in settings,
|
||||
/// OR if `FeatureFlag::AgentView` is disabled and the user has 'Classic' input type selected
|
||||
/// in settings.
|
||||
pub(super) fn render_classic_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
let should_render_prompt_using_editor_decorator_elements =
|
||||
should_render_prompt_using_editor_decorator_elements(
|
||||
false,
|
||||
&self.ai_input_model,
|
||||
&model,
|
||||
app,
|
||||
);
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let mut prompt_row = Stack::new();
|
||||
|
||||
let (lprompt_top_area_option, rprompt_area_option);
|
||||
let mut prompt_top_padding_row = Stack::new();
|
||||
if should_render_prompt_using_editor_decorator_elements {
|
||||
// These are rendered as sections/notches in the EditorElement.
|
||||
lprompt_top_area_option = None;
|
||||
rprompt_area_option = None;
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
let default_prompt_top_padding = terminal_spacing.block_padding.padding_top
|
||||
* self.size_info(app).cell_height_px().as_f32()
|
||||
- get_input_box_top_border_width();
|
||||
let prompt_top_padding_element = Container::new(Empty::new().finish())
|
||||
.with_padding_top(default_prompt_top_padding)
|
||||
.finish();
|
||||
prompt_top_padding_row.add_child(prompt_top_padding_element);
|
||||
} else {
|
||||
let prompt_elements = self
|
||||
.prompt_render_helper
|
||||
.render_prompt_areas(&model, appearance, app);
|
||||
lprompt_top_area_option = prompt_elements.lprompt;
|
||||
rprompt_area_option = prompt_elements.rprompt;
|
||||
}
|
||||
|
||||
if !should_render_prompt_using_editor_decorator_elements {
|
||||
if let Some(lprompt_top_area) = lprompt_top_area_option {
|
||||
prompt_row.add_child(lprompt_top_area);
|
||||
}
|
||||
if let Some(rprompt_area) = rprompt_area_option {
|
||||
let block = &model.block_list().active_block();
|
||||
prompt_row.add_positioned_child(
|
||||
rprompt_area,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
block.rprompt_render_offset(&self.size_info(app)),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let vim_state = self.editor.as_ref(app).vim_state(app);
|
||||
let app_editor_settings = AppEditorSettings::as_ref(app);
|
||||
let show_vim_status = vim_state.is_some() && *app_editor_settings.vim_status_bar.value();
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
let is_compact_mode = matches!(
|
||||
TerminalSettings::as_ref(app).spacing_mode.value(),
|
||||
SpacingMode::Compact
|
||||
);
|
||||
let mut column = Flex::column();
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToBottom | InputMode::Waterfall) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(banner);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_children([prompt_top_padding_row.finish(), prompt_row.finish()]);
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI)
|
||||
&& !FeatureFlag::AgentView.is_enabled()
|
||||
{
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_padding_bottom(spacing::CLASSIC_PROMPT_ATTACH_IMAGES_BOTTOM_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(self.render_input_box(show_vim_status, appearance, app));
|
||||
|
||||
if should_show_terminal_input_message_bar(&model, app) {
|
||||
column.add_child(
|
||||
Clipped::new(ChildView::new(&self.terminal_input_message_bar).finish()).finish(),
|
||||
);
|
||||
} else if !(matches!(input_mode, InputMode::PinnedToTop)
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_menu_open())
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(Flex::row().finish())
|
||||
.with_margin_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToTop) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(banner);
|
||||
}
|
||||
}
|
||||
|
||||
let subshell_flag = self.get_subshell_flag_render_state(&model, is_compact_mode, app);
|
||||
|
||||
let should_extend_flag = subshell_flag.is_some();
|
||||
|
||||
if should_extend_flag {
|
||||
let max_height = self.size_info(app).pane_height_px().as_f32();
|
||||
stack.add_positioned_child(
|
||||
render_subshell_flag_pole(max_height, theme.subshell_background()),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(SubshellRenderState::Flag(command)) = subshell_flag {
|
||||
let flag = render_subshell_flag(
|
||||
command,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
theme,
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
flag,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
if let Some(vim_state) = vim_state.as_ref() {
|
||||
if show_vim_status {
|
||||
add_vim_status_to_stack(
|
||||
&mut stack, vim_state, appearance,
|
||||
false, // legacy doesn't use adjusted padding for vim status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false, // legacy uses full padding
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
// When AgentView is enabled, match terminal-mode input behavior and only render the
|
||||
// divider adjacent to the status/message line when block dividers are enabled.
|
||||
let show_block_dividers = *BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
let should_render_divider = !FeatureFlag::AgentView.is_enabled() || show_block_dividers;
|
||||
|
||||
let border = match input_mode {
|
||||
InputMode::PinnedToBottom => Border::top(if should_render_divider {
|
||||
get_input_box_top_border_width()
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.with_border_fill(theme.outline()),
|
||||
InputMode::PinnedToTop => Border::bottom(if should_render_divider {
|
||||
get_input_box_top_border_width()
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.with_border_fill(theme.outline()),
|
||||
InputMode::Waterfall => Border::new(get_input_box_top_border_width())
|
||||
.with_sides(true, false, true, false)
|
||||
.with_border_fill(theme.outline()),
|
||||
};
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
Container::new(stack.finish()).with_border(border).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = SavePosition::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
&self.status_free_input_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
let is_slash_commands = self.suggestions_mode_model.as_ref(app).is_slash_commands();
|
||||
let is_conversation_menu = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_conversation_menu();
|
||||
let is_model_selector = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_model_selector();
|
||||
let is_prompts_menu = self.suggestions_mode_model.as_ref(app).is_prompts_menu();
|
||||
let is_skill_menu = self.suggestions_mode_model.as_ref(app).is_skill_menu();
|
||||
let is_inline_history_menu = FeatureFlag::InlineHistoryMenu.is_enabled()
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu();
|
||||
let is_repos_menu = FeatureFlag::InlineRepoMenu.is_enabled()
|
||||
&& self.suggestions_mode_model.as_ref(app).is_repos_menu();
|
||||
|
||||
match input_mode {
|
||||
InputMode::PinnedToBottom => {
|
||||
column.add_children(
|
||||
[
|
||||
if is_model_selector {
|
||||
Some(ChildView::new(&self.inline_model_selector_view).finish())
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
Some(input),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::PinnedToTop => {
|
||||
column.add_children(
|
||||
[
|
||||
Some(input),
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
if is_model_selector {
|
||||
Some(ChildView::new(&self.inline_model_selector_view).finish())
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::Waterfall => {
|
||||
let should_render_below = self
|
||||
.inline_terminal_menu_positioner
|
||||
.as_ref(app)
|
||||
.should_render_inline_menu_below_input();
|
||||
|
||||
if is_slash_commands && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& !should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
|
||||
if is_model_selector && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_model_selector_view).finish());
|
||||
} else if is_slash_commands && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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 warp_core::ui::{
|
||||
color::{contrast::MinimumAllowedContrast, ContrastingColor},
|
||||
theme::color::internal_colors,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
|
||||
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _, ViewContext,
|
||||
};
|
||||
|
||||
impl Input {
|
||||
/// Renders the CLI rich input (editor + CLI agent footer).
|
||||
pub(super) fn render_cli_agent_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let input_box = Container::new(
|
||||
ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish())
|
||||
.with_max_height(CLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(CLI_AGENT_RICH_INPUT_EDITOR_TOP_PADDING)
|
||||
.with_padding_right(*TERMINAL_VIEW_PADDING_LEFT)
|
||||
.with_padding_bottom(CLI_AGENT_RICH_INPUT_EDITOR_BOTTOM_PADDING)
|
||||
.finish();
|
||||
|
||||
let input_editor_save_position_id = self.editor_save_position_id();
|
||||
let editor_element = SavePosition::new(
|
||||
EventHandler::new(input_box)
|
||||
.on_right_mouse_down(move |ctx, _, position| {
|
||||
let input_rect = ctx
|
||||
.element_position_by_id(input_editor_save_position_id.clone())
|
||||
.expect("input editor position id should be saved");
|
||||
let offset_position = position - input_rect.origin();
|
||||
ctx.dispatch_typed_action(TerminalAction::OpenInputContextMenu {
|
||||
position: offset_position,
|
||||
});
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
&self.editor_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
// Render attachment chips (e.g. pasted screenshots) above the editor,
|
||||
// matching the pattern used by the agent view input in agent.rs.
|
||||
if FeatureFlag::ImageAsContext.is_enabled() {
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(editor_element);
|
||||
column.add_child(
|
||||
SavePosition::new(
|
||||
Container::new(ChildView::new(&self.agent_input_footer).finish())
|
||||
.with_padding_right(*TERMINAL_VIEW_PADDING_LEFT)
|
||||
.finish(),
|
||||
&self.prompt_save_position_id(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false,
|
||||
));
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
let mut input_container = Container::new(stack.finish()).with_border(
|
||||
Border::top(1.0).with_border_fill(internal_colors::fg_overlay_2(appearance.theme())),
|
||||
);
|
||||
|
||||
// When an alt screen CLI agent (e.g. OpenCode) is running, match
|
||||
// the rich input background to the alt screen so it blends in.
|
||||
{
|
||||
let terminal_model = self.model.lock();
|
||||
if terminal_model.is_alt_screen_active() {
|
||||
if let Some(bg_color) = terminal_model.alt_screen().inferred_bg_color() {
|
||||
input_container = input_container.with_background(bg_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
input_container.finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = SavePosition::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
&self.status_free_input_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Render inline menus (slash commands, prompts, skills) above the input,
|
||||
// matching the pattern used by the agent view input in agent.rs.
|
||||
// These must be outside the Hoverable so that mouse events on the menu
|
||||
// don't trigger SetUDIHovered, which would cause layout jitter.
|
||||
let mut outer_column = Flex::column();
|
||||
if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_skill_menu() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
}
|
||||
outer_column.add_child(input);
|
||||
|
||||
SavePosition::new(outer_column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
/// Keep the rich input editor's text colors legible when it's rendered on
|
||||
/// top of an alt-screen CLI agent's inferred background (e.g. OpenCode),
|
||||
/// which does not respect the Warp theme. When no alt-screen-backed CLI
|
||||
/// agent rich input is active, restores the theme default text colors.
|
||||
///
|
||||
/// This mirrors the contrast-adjustment pattern used for the use-agent
|
||||
/// toolbar button text (see `AgentFooterButtonTheme::text_color`) and the
|
||||
/// CLI agent brand icon in `AgentInputFooter::render_cli_mode_footer`.
|
||||
pub(super) fn update_cli_agent_editor_text_colors(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let default_colors = TextColors::from_appearance(appearance);
|
||||
|
||||
// Only override while the CLI agent rich input is actually open - the
|
||||
// same editor is reused for the normal terminal input and for other
|
||||
// modes (AI, shared sessions), and those shouldn't see the override.
|
||||
let rich_input_open =
|
||||
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
|
||||
|
||||
let alt_screen_bg = if rich_input_open {
|
||||
let terminal_model = self.model.lock();
|
||||
terminal_model
|
||||
.is_alt_screen_active()
|
||||
.then(|| terminal_model.alt_screen().inferred_bg_color())
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_colors = match alt_screen_bg {
|
||||
Some(bg) => TextColors {
|
||||
default_color: default_colors
|
||||
.default_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
disabled_color: default_colors
|
||||
.disabled_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
hint_color: default_colors
|
||||
.hint_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
},
|
||||
None => default_colors,
|
||||
};
|
||||
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_text_colors(text_colors, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Radius, Text,
|
||||
};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_history::{
|
||||
AcceptHistoryItem, HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView,
|
||||
};
|
||||
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::model::session::active_session::ActiveSession;
|
||||
|
||||
const MENU_MAX_HEIGHT: f32 = 168.;
|
||||
|
||||
const MENU_VERTICAL_PADDING: f32 = 4.;
|
||||
|
||||
const MENU_CORNER_RADIUS: f32 = 6.;
|
||||
|
||||
const DROP_SHADOW_COLOR: ColorU = ColorU {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 77,
|
||||
};
|
||||
|
||||
pub struct CloudModeV2HistoryMenuView {
|
||||
inner: ViewHandle<InlineHistoryMenuView>,
|
||||
}
|
||||
|
||||
impl CloudModeV2HistoryMenuView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
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(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&inner, |_, _, event, ctx| {
|
||||
ctx.emit(event.clone());
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.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));
|
||||
}
|
||||
|
||||
pub fn accept_selected(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.update(ctx, |v, ctx| v.accept_selected_item(ctx));
|
||||
}
|
||||
|
||||
pub fn has_selection(&self, app: &AppContext) -> bool {
|
||||
self.inner
|
||||
.as_ref(app)
|
||||
.model()
|
||||
.as_ref(app)
|
||||
.selected_item()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn selected_query_text(&self, app: &AppContext) -> Option<String> {
|
||||
match self.inner.as_ref(app).model().as_ref(app).selected_item()? {
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text.clone()),
|
||||
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::Conversation { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudModeV2HistoryMenuView {
|
||||
type Event = InlineHistoryMenuEvent;
|
||||
}
|
||||
|
||||
impl View for CloudModeV2HistoryMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"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)
|
||||
.finish();
|
||||
|
||||
let padded = Container::new(constrained)
|
||||
.with_padding_top(MENU_VERTICAL_PADDING)
|
||||
.with_padding_bottom(MENU_VERTICAL_PADDING)
|
||||
.finish();
|
||||
|
||||
Container::new(padded)
|
||||
.with_background(background)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS)))
|
||||
.with_drop_shadow(DropShadow::new_with_standard_offset_and_spread(
|
||||
DROP_SHADOW_COLOR,
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
use vim::vim::{VimMode, VimState};
|
||||
use warp_completer::completer::Description;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
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,
|
||||
};
|
||||
|
||||
/// Whether the terminal input message bar should be shown.
|
||||
///
|
||||
/// The message bar is hidden when AI is disabled, the user has turned it off in settings,
|
||||
/// or the session is a shared ambient agent session.
|
||||
pub(super) fn should_show_terminal_input_message_bar(
|
||||
model: &TerminalModel,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
FeatureFlag::AgentView.is_enabled()
|
||||
&& !FeatureFlag::AgentViewPromptChip.is_enabled()
|
||||
&& InputSettings::as_ref(app).is_terminal_input_message_bar_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
&& !model.is_shared_ambient_agent_session()
|
||||
}
|
||||
|
||||
/// Renders vim status bar
|
||||
/// Used by: agent.rs, terminal.rs, universal.rs, legacy.rs
|
||||
pub(super) fn render_vim_status(vim_state: &VimState, appearance: &Appearance) -> Container {
|
||||
let theme = appearance.theme();
|
||||
let ansi_colors = theme.terminal_colors().bright;
|
||||
let icon = match vim_state.mode {
|
||||
VimMode::Normal => Icon::VimNormalMode.to_warpui_icon(ansi_colors.green.into()),
|
||||
VimMode::Insert => {
|
||||
use crate::themes::theme::Blend;
|
||||
Icon::VimInsertMode.to_warpui_icon(
|
||||
theme
|
||||
.background()
|
||||
.blend(&theme.foreground().with_opacity(50)),
|
||||
)
|
||||
}
|
||||
VimMode::Visual(_) => Icon::VimVisualMode.to_warpui_icon(ansi_colors.blue.into()),
|
||||
VimMode::Replace => Icon::VimReplaceMode.to_warpui_icon(ansi_colors.red.into()),
|
||||
};
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
vim_state.showcmd.to_owned(),
|
||||
appearance.monospace_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.with_margin_bottom(2.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_width(12.)
|
||||
.with_height(12.)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.with_margin_bottom(4.)
|
||||
}
|
||||
|
||||
/// Renders the vim status indicator in the bottom right corner of the given stack.
|
||||
pub(super) fn add_vim_status_to_stack(
|
||||
stack: &mut Stack,
|
||||
vim_state: &VimState,
|
||||
appearance: &Appearance,
|
||||
use_adjusted_padding: bool,
|
||||
) {
|
||||
let terminal_padding = if use_adjusted_padding {
|
||||
*PADDING_LEFT / 1.5
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
let status_bar = render_vim_status(vim_state, appearance)
|
||||
.with_padding_bottom(4.)
|
||||
.with_uniform_margin(terminal_padding)
|
||||
.finish();
|
||||
stack.add_positioned_child(
|
||||
status_bar,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::BottomRight,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wraps the given column, assumed to represent the full input content, with appropriate
|
||||
/// left padding to be consistent with the terminal content, as well as an event handler to
|
||||
/// focus the input view when clicked.
|
||||
pub(super) fn wrap_input_with_terminal_padding_and_focus_handler(
|
||||
is_active_session: bool,
|
||||
column: Box<dyn Element>,
|
||||
use_adjusted_padding: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let terminal_padding = if use_adjusted_padding {
|
||||
*PADDING_LEFT / 1.5
|
||||
} else {
|
||||
*PADDING_LEFT
|
||||
};
|
||||
|
||||
if is_active_session {
|
||||
let mut flex_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
|
||||
flex_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(column)
|
||||
.with_padding_left(terminal_padding)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
EventHandler::new(flex_row.finish())
|
||||
.on_left_mouse_down(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
|
||||
ctx.dispatch_typed_action(InputAction::FocusInputBox);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(column)
|
||||
.with_padding_left(terminal_padding)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the selected workflow info overlay over the input.
|
||||
pub(super) fn add_workflow_info_overlay(
|
||||
stack: &mut Stack,
|
||||
selected_workflow_state: &super::SelectedWorkflowState,
|
||||
pane_height_px: f32,
|
||||
menu_positioning: MenuPositioning,
|
||||
) {
|
||||
let workflows_info_view =
|
||||
Container::new(ChildView::new(&selected_workflow_state.more_info_view).finish())
|
||||
.with_margin_left(16.)
|
||||
.with_margin_right(16.)
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(workflows_info_view)
|
||||
.with_max_height(pane_height_px * 0.35)
|
||||
.finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.workflows_info_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the voltron overlay over the input.
|
||||
pub(super) fn add_voltron_overlay(
|
||||
stack: &mut Stack,
|
||||
voltron_view: &ViewHandle<crate::voltron::Voltron>,
|
||||
menu_positioning: MenuPositioning,
|
||||
) {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(voltron_view).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
menu_positioning.voltron_offset(),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
menu_positioning.voltron_parent_anchor(),
|
||||
menu_positioning.voltron_child_anchor(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the appropriate input suggestions overlay over the input, bsaed on the current input
|
||||
/// suggestions mode (if any).
|
||||
pub(super) fn add_input_suggestions_overlays(
|
||||
input: &Input,
|
||||
stack: &mut Stack,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
app: &AppContext,
|
||||
) {
|
||||
match input.suggestions_mode_model().as_ref(app).mode() {
|
||||
InputSuggestionsMode::HistoryUp { .. } => {
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_history_up_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::Unbounded,
|
||||
menu_positioning.history_y_offset(),
|
||||
menu_positioning.history_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::CompletionSuggestions { menu_position, .. } => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_completion_suggestions_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::StaticWorkflowEnumSuggestions { menu_position, .. } => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_workflow_enum_suggestions_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
|
||||
menu_position,
|
||||
dynamic_enum_status,
|
||||
command,
|
||||
suggestions,
|
||||
..
|
||||
} => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_dynamic_workflow_enum_menu(
|
||||
appearance,
|
||||
menu_positioning,
|
||||
command.clone(),
|
||||
dynamic_enum_status.clone(),
|
||||
suggestions,
|
||||
),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::AIContextMenu { .. } => {
|
||||
input.render_ai_context_menu(stack, &menu_positioning, app);
|
||||
}
|
||||
// SlashCommandsMenu is rendered separately via inline_slash_commands_menu_view
|
||||
InputSuggestionsMode::SlashCommands => {}
|
||||
// Conversation menu is rendered separately via inline_conversation_menu_view
|
||||
InputSuggestionsMode::ConversationMenu => {}
|
||||
// Model selector is rendered separately via inline_model_selector_view
|
||||
InputSuggestionsMode::ModelSelector => {}
|
||||
// Profile selector is rendered separately via inline_profile_selector_view
|
||||
InputSuggestionsMode::ProfileSelector => {}
|
||||
// Prompts menu is rendered separately via inline_prompts_menu_view
|
||||
InputSuggestionsMode::PromptsMenu => {}
|
||||
// Skill menu is rendered separately via inline_skill_selector_view
|
||||
InputSuggestionsMode::SkillMenu => {}
|
||||
// User query menu is rendered separately via user_query_menu_view
|
||||
InputSuggestionsMode::UserQueryMenu { .. } => {}
|
||||
// Inline history menu is rendered separately via inline_history_menu_view
|
||||
InputSuggestionsMode::InlineHistoryMenu { .. } => {}
|
||||
// Repos menu is rendered separately via inline_repos_menu_view
|
||||
InputSuggestionsMode::IndexedReposMenu => {}
|
||||
// Plan menu is rendered separately via inline_plan_menu_view
|
||||
InputSuggestionsMode::PlanMenu { .. } => {}
|
||||
InputSuggestionsMode::Closed => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the command xray overlay on the input using the command x ray-specific position id.
|
||||
pub(super) fn add_command_xray_overlay(
|
||||
input: &Input,
|
||||
stack: &mut Stack,
|
||||
token_description: &Arc<Description>,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let command_x_ray_position_id = format!("editor:command_x_ray_{}", input.editor.id());
|
||||
let line_height = input
|
||||
.editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance);
|
||||
let offset = match menu_positioning {
|
||||
MenuPositioning::AboveInputBox => OffsetType::Pixel(0.),
|
||||
MenuPositioning::BelowInputBox => OffsetType::Pixel(line_height),
|
||||
};
|
||||
stack.add_positioned_overlay_child(
|
||||
render_command_token_description(token_description, appearance),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&command_x_ray_position_id,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&command_x_ray_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
offset,
|
||||
menu_positioning.command_xray_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_command_token_description(
|
||||
description: &Arc<Description>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Append an ellipsis to the description if the token has more characters than the max
|
||||
// number of characters that are allowed.
|
||||
const MAX_XRAY_LABEL_CHARS: usize = 16;
|
||||
const TOKEN_DESCRIPTION_PADDING: f32 = 12.;
|
||||
const TOKEN_DESCRIPTION_MARGIN: f32 = 10.;
|
||||
const TOKEN_DESCRIPTION_WIDTH: f32 = 240.;
|
||||
const TOKEN_LABEL_HORIZONTAL_PADDING: f32 = 8.;
|
||||
const TOKEN_LABEL_VERTICAL_PADDING: f32 = 4.;
|
||||
|
||||
let truncated_label = match description
|
||||
.token
|
||||
.item
|
||||
.char_indices()
|
||||
.nth(MAX_XRAY_LABEL_CHARS)
|
||||
{
|
||||
None => description.token.item.clone(),
|
||||
Some((byte_index, _)) => format!("{}...", &description.token[..byte_index]),
|
||||
};
|
||||
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let mut command_description = Flex::column().with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(truncated_label)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_color: Some(theme.active_ui_text_color().into()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(2.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(description.suggestion_type.to_name().to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(theme.active_ui_text_color().into()),
|
||||
font_size: Some(appearance.monospace_font_size() * 0.75),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_background(theme.outline())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_margin_left(TOKEN_DESCRIPTION_MARGIN)
|
||||
.with_padding_left(TOKEN_LABEL_HORIZONTAL_PADDING)
|
||||
.with_padding_right(TOKEN_LABEL_HORIZONTAL_PADDING)
|
||||
.with_padding_top(TOKEN_LABEL_VERTICAL_PADDING)
|
||||
.with_padding_bottom(TOKEN_LABEL_VERTICAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(description_text) = description.description_text.clone() {
|
||||
command_description.add_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(description_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(theme.sub_text_color(theme.surface_2()).into()),
|
||||
font_size: Some(appearance.monospace_font_size() * 0.9),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(TOKEN_DESCRIPTION_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(command_description.finish())
|
||||
.with_uniform_padding(TOKEN_DESCRIPTION_PADDING)
|
||||
.with_margin_bottom(TOKEN_DESCRIPTION_MARGIN)
|
||||
.with_border(Border::all(1.).with_border_fill(theme.split_pane_border_color()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background_color(theme.surface_2().into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(TOKEN_DESCRIPTION_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Conditionally adds the "buy credits" banner overlay.
|
||||
/// The overlay only is shown if all of the following is true:
|
||||
/// - The user is on a team that can purchase addon credits
|
||||
/// - The user is out of credits (or at their auto-reload limit)
|
||||
/// - The input is focused
|
||||
/// - There is not a BYO API key for the current model
|
||||
pub(super) fn maybe_add_buy_credits_banner(
|
||||
stack: &mut Stack,
|
||||
buy_credits_banner: &ViewHandle<BuyCreditsBanner>,
|
||||
is_focused: bool,
|
||||
terminal_view_id: EntityId,
|
||||
is_input_at_top: bool,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let can_purchase_addon_credits = UserWorkspaces::as_ref(app)
|
||||
.current_team()
|
||||
.and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy)
|
||||
.is_some_and(|policy| policy.enabled);
|
||||
|
||||
// Show buy credits banner if billing policy allows purchasing, input is focused,
|
||||
// and either:
|
||||
// 1. OutOfCredits: for users that are not auto-reload enabled
|
||||
// 2. MonthlyLimitReached: Auto-reload enabled and is blocked by monthly limit
|
||||
let ai_request_usage = AIRequestUsageModel::as_ref(app);
|
||||
let should_show_banner = !matches!(
|
||||
ai_request_usage.compute_buy_addon_credits_banner_display_state(app),
|
||||
BuyCreditsBannerDisplayState::Hidden
|
||||
);
|
||||
let is_using_api_key_for_current_model = is_using_api_key_for_provider(
|
||||
&LLMPreferences::as_ref(app)
|
||||
.get_active_base_model(app, Some(terminal_view_id))
|
||||
.provider,
|
||||
app,
|
||||
);
|
||||
if can_purchase_addon_credits
|
||||
&& is_focused
|
||||
&& should_show_banner
|
||||
&& !is_using_api_key_for_current_model
|
||||
{
|
||||
add_buy_credits_banner_overlay(stack, buy_credits_banner, is_input_at_top);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds buy credits banner overlay to stack
|
||||
fn add_buy_credits_banner_overlay(
|
||||
stack: &mut Stack,
|
||||
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.)
|
||||
} else {
|
||||
(ParentAnchor::TopLeft, ChildAnchor::BottomLeft, -8.)
|
||||
};
|
||||
stack.add_positioned_child(
|
||||
ChildView::new(buy_credits_banner).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., y_offset),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
parent_anchor,
|
||||
child_anchor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Data source for the inline conversation menu.
|
||||
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, ModelHandle};
|
||||
|
||||
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;
|
||||
|
||||
pub struct ConversationMenuDataSource {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
}
|
||||
|
||||
impl ConversationMenuDataSource {
|
||||
pub fn new(
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent_view_controller,
|
||||
active_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ConversationMenuDataSource {
|
||||
type Action = AcceptConversation;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let conversation_navigation_data = ConversationNavigationData::all_conversations(app);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
let active_conversation_id = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id();
|
||||
|
||||
let filter_by_cwd = query
|
||||
.filters
|
||||
.contains(&QueryFilter::CurrentDirectoryConversations);
|
||||
let session_pwd = if filter_by_cwd {
|
||||
self.active_session
|
||||
.as_ref(app)
|
||||
.current_working_directory()
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When the "Current Directory" filter is active, include only conversations
|
||||
// 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 {
|
||||
if !filter_by_cwd {
|
||||
return true;
|
||||
}
|
||||
let Some(session_pwd) = session_pwd.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
data.latest_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)
|
||||
})
|
||||
};
|
||||
|
||||
if query_text.is_empty() {
|
||||
// By default, show 50 most recent conversations in the list.
|
||||
const DEFAULT_RESULT_COUNT: usize = 50;
|
||||
|
||||
// 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
|
||||
.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))
|
||||
.take(DEFAULT_RESULT_COUNT)
|
||||
.map(|navigation_data| {
|
||||
QueryResult::from(ConversationSearchItem::new(navigation_data, app))
|
||||
})
|
||||
.rev()
|
||||
.collect())
|
||||
} else {
|
||||
let mut search_results = conversation_navigation_data
|
||||
.into_iter()
|
||||
.filter_map(|navigation_data| {
|
||||
if Some(navigation_data.id()) == active_conversation_id {
|
||||
// Don't show the currently open conversation, that's redundant.
|
||||
return None;
|
||||
}
|
||||
if !matches_directory(&navigation_data) {
|
||||
return None;
|
||||
}
|
||||
let match_result = fuzzy_match::match_indices_case_insensitive(
|
||||
&navigation_data.title,
|
||||
&query_text,
|
||||
)?;
|
||||
|
||||
// 25 is arbitrary.
|
||||
if match_result.score < 25 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
ConversationSearchItem::new(navigation_data, app)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.sorted_by(|a, b| b.score().cmp(&a.score()))
|
||||
.collect_vec();
|
||||
|
||||
// This is basically here so the app doesn't choke.
|
||||
const MAX_SEARCH_RESULTS: usize = 500;
|
||||
|
||||
search_results.truncate(MAX_SEARCH_RESULTS);
|
||||
Ok(search_results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ConversationMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Inline conversation menu for selecting AI conversations, enabled
|
||||
//! when `FeatureFlag::AgentView` is enabled.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{keymap::Keystroke, SingletonEntity};
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
/// Tab identifiers for the inline conversation menu.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InlineConversationMenuTab {
|
||||
/// Show all conversations.
|
||||
All,
|
||||
/// Show only conversations whose most recent directory matches the session's CWD.
|
||||
CurrentDirectory,
|
||||
}
|
||||
|
||||
/// Action emitted when enter is hit on a conversation the inline conversation menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptConversation {
|
||||
pub navigation_data: ConversationNavigationData,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptConversation {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ConversationMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
} = args;
|
||||
|
||||
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 text = if is_active {
|
||||
" go to conversation"
|
||||
} else {
|
||||
" continue in this pane"
|
||||
};
|
||||
|
||||
let navigation_data = data.clone();
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(text),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: AcceptConversation {
|
||||
navigation_data: navigation_data.clone(),
|
||||
},
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept.clone(),
|
||||
));
|
||||
} else {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let disabled_color = theme.disabled_text_color(theme.background()).into_solid();
|
||||
items.extend([
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: Some(disabled_color),
|
||||
background_color: Some(ColorU::transparent_black()),
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: " continue in this pane".into(),
|
||||
color: Some(disabled_color),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! SearchItem implementation for conversation menu items.
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
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::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::conversations::AcceptConversation;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
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,
|
||||
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());
|
||||
|
||||
Self {
|
||||
navigation_data,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
conversation_status,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ConversationSearchItem {
|
||||
type Action = AcceptConversation;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
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_warpui_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()
|
||||
}
|
||||
};
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
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 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 full_text = if should_show_suffix {
|
||||
format!("{title}{secondary_suffix}")
|
||||
} else {
|
||||
title.clone()
|
||||
};
|
||||
|
||||
let mut name_text = Text::new_inline(full_text, appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if should_show_suffix {
|
||||
let secondary_range = title.len()..(title.len() + secondary_suffix.len());
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
})
|
||||
.with_foreground_color(secondary_text_color.into()),
|
||||
secondary_range.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., name_text.finish()).finish());
|
||||
|
||||
// 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()),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptConversation {
|
||||
navigation_data: self.navigation_data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Conversation: {}", self.navigation_data.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Inline conversation menu view for selecting AI conversations.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
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;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::conversations::data_source::ConversationMenuDataSource;
|
||||
use crate::terminal::input::conversations::{AcceptConversation, InlineConversationMenuTab};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuPositioner, InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
/// Events emitted by InlineConversationMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineConversationMenuEvent {
|
||||
/// User 'accepted' a conversation (hit enter).
|
||||
NavigateToConversation {
|
||||
conversation_navigation_data: Box<ConversationNavigationData>,
|
||||
},
|
||||
/// User dismissed the menu (escape or click).
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
static TAB_CONFIGS: LazyLock<Vec<InlineMenuTabConfig<InlineConversationMenuTab>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut configs = vec![InlineMenuTabConfig {
|
||||
id: InlineConversationMenuTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
}];
|
||||
if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
configs.push(InlineMenuTabConfig {
|
||||
id: InlineConversationMenuTab::CurrentDirectory,
|
||||
label: "Current Directory".to_string(),
|
||||
filters: HashSet::from([QueryFilter::CurrentDirectoryConversations]),
|
||||
});
|
||||
}
|
||||
configs
|
||||
});
|
||||
|
||||
pub struct InlineConversationMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptConversation, InlineConversationMenuTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptConversation>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlineConversationMenuView {
|
||||
pub fn new(
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| {
|
||||
ConversationMenuDataSource::new(agent_view_controller.clone(), active_session)
|
||||
});
|
||||
|
||||
let tab_configs = TAB_CONFIGS.clone();
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptConversation>::new();
|
||||
mixer.add_sync_source(data_source, [QueryFilter::CurrentDirectoryConversations]);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
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()),
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => (),
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineConversationMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if input_suggestions_model.as_ref(ctx).is_conversation_menu() {
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me
|
||||
.input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
let InputBufferUpdateEvent { new_content: _, .. } = event;
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&active_agent_views_model, |me, _, _, ctx| {
|
||||
if me
|
||||
.input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self
|
||||
.menu_view
|
||||
.as_ref(ctx)
|
||||
.model()
|
||||
.as_ref(ctx)
|
||||
.active_tab_filters();
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: self
|
||||
.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.to_owned(),
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineConversationMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineConversationMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineConversationMenuView {
|
||||
type Event = InlineConversationMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! 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 settings::Setting as _;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity, ViewContext};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::{EmptyCompletionContext, SessionContext},
|
||||
editor::TextStyleOperation,
|
||||
settings::InputSettings,
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
};
|
||||
|
||||
use super::Input;
|
||||
|
||||
pub use warp_completer::{
|
||||
completer::SuggestionTypeName, util::parse_current_commands_and_tokens, ParsedTokenData,
|
||||
ParsedTokensSnapshot,
|
||||
};
|
||||
|
||||
/// Options to enable/disable command decoration and/or AI input background tasks spawned on input
|
||||
/// edits.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct InputBackgroundJobOptions {
|
||||
command_decoration: bool,
|
||||
ai_input_detection: bool,
|
||||
}
|
||||
|
||||
impl InputBackgroundJobOptions {
|
||||
pub fn with_command_decoration(mut self) -> Self {
|
||||
self.command_decoration = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_ai_input_detection(mut self) -> Self {
|
||||
self.ai_input_detection = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns `true` if there are no input background jobs to run. Returns `false` if there is at
|
||||
/// least one job to run.
|
||||
fn no_jobs_to_run(self) -> bool {
|
||||
!self.command_decoration && !self.ai_input_detection
|
||||
}
|
||||
}
|
||||
|
||||
// Characters that will make us ignore commands for error underlining - largely
|
||||
// a temporary solution till our parser improves (to handle special edge cases).
|
||||
// Note that some of these are redundant since we split on tokens such as "&"
|
||||
// but including them to be defensive.
|
||||
// TODO: Remove "," once we differentiate between brace expansion and grouped commands
|
||||
// at the parsing level.
|
||||
const INVALID_SYMBOLS_COMMAND_ERROR_UNDERLINING: [char; 22] = [
|
||||
'~', '`', '#', '$', '&', '*', '(', ')', '\\', '|', '[', ']', '{', '}', ';', '\'', '\"', '<',
|
||||
'>', '?', '!', ',',
|
||||
];
|
||||
|
||||
enum CompletionSessionContext {
|
||||
Session(SessionContext),
|
||||
Empty(EmptyCompletionContext),
|
||||
}
|
||||
|
||||
/// Returns boolean indicating whether we should attempt to red underline
|
||||
/// the command or not (this is a stop-gap since our parser doesn't cover
|
||||
/// all the edge cases for commands currently e.g. "!!"). We don't want
|
||||
/// to incorrectly red underline a valid command. In other words,
|
||||
/// we would rather miss red underlining an invalid command compared to
|
||||
/// incorrectly red underlining a valid command.
|
||||
fn valid_command_for_error_underline(command: &str) -> bool {
|
||||
command
|
||||
.chars()
|
||||
.all(|x| !INVALID_SYMBOLS_COMMAND_ERROR_UNDERLINING.contains(&x))
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn completion_session_context_or_empty_context(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> CompletionSessionContext {
|
||||
self.completion_session_context(ctx)
|
||||
.map(CompletionSessionContext::Session)
|
||||
.unwrap_or_else(|| CompletionSessionContext::Empty(EmptyCompletionContext::new()))
|
||||
}
|
||||
|
||||
/// Whether or not any decorations should be computed and applied to the
|
||||
/// input text.
|
||||
pub fn should_apply_decorations(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
self.should_show_syntax_highlighting(ctx) || self.should_show_error_underlining(ctx)
|
||||
}
|
||||
|
||||
/// Whether or not syntax highlighting should be computed and applied to the
|
||||
/// input text.
|
||||
fn should_show_syntax_highlighting(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
*InputSettings::as_ref(ctx).syntax_highlighting.value()
|
||||
}
|
||||
|
||||
/// Whether or not error underlining should be computed and applied to the
|
||||
/// input text.
|
||||
fn should_show_error_underlining(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
*InputSettings::as_ref(ctx).error_underlining.value()
|
||||
}
|
||||
|
||||
fn run_input_mode_detection(
|
||||
&self,
|
||||
completion_context: SessionContext,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(parsed_token) = self.last_parsed_tokens.clone() {
|
||||
let session_id = completion_context.session.id();
|
||||
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
|
||||
ai_input_model.detect_and_set_input_type(
|
||||
parsed_token,
|
||||
completion_context,
|
||||
Some(session_id),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies background highlighting to slash command and skill command prefixes that should be
|
||||
/// syntax highlighted.
|
||||
fn apply_slash_command_prefix_highlighting(
|
||||
&mut self,
|
||||
buffer_text: &str,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
let highlighted_prefix_len = self
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.state()
|
||||
.command_prefix_highlight_len(buffer_text);
|
||||
|
||||
let Some(highlighted_prefix_len) = highlighted_prefix_len else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let theme = Appearance::as_ref(ctx).theme();
|
||||
let color = theme.ansi_fg_magenta();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
vec![CharOffset::from(0)..CharOffset::from(highlighted_prefix_len)],
|
||||
TextStyleOperation::default().set_syntax_color(color),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Computes information about the currently-entered command in a background
|
||||
/// task and then uses it to decorate the input, specifically applying
|
||||
/// styles for syntax highlighting and error underlining.
|
||||
/// Includes a short-circuit that lets us clear formatting and return without parsing the input.
|
||||
pub fn run_input_background_jobs(
|
||||
&mut self,
|
||||
mode: InputBackgroundJobOptions,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if mode.no_jobs_to_run() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut mode = mode;
|
||||
|
||||
// We don't show input command decorations in AI mode, but we keep slash command prefix highlighting.
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
if self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
|
||||
|| (FeatureFlag::AgentView.is_enabled()
|
||||
&& self
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.state()
|
||||
.is_detected_command_or_skill())
|
||||
{
|
||||
self.clear_decorations(ctx);
|
||||
self.apply_slash_command_prefix_highlighting(&buffer_text, ctx);
|
||||
mode.command_decoration = false;
|
||||
|
||||
// Return early because there are no input background jobs to run.
|
||||
if mode.no_jobs_to_run() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match self.completion_session_context_or_empty_context(ctx) {
|
||||
CompletionSessionContext::Session(completion_context) => {
|
||||
let editor = self.editor.as_ref(ctx);
|
||||
let buffer_text = editor.buffer_text(ctx);
|
||||
|
||||
if matches!(&self.last_parsed_tokens, Some(last_parsed_tokens) if buffer_text == last_parsed_tokens.buffer_text)
|
||||
{
|
||||
if mode.ai_input_detection {
|
||||
self.run_input_mode_detection(completion_context, ctx);
|
||||
}
|
||||
|
||||
if mode.command_decoration {
|
||||
self.apply_decorations(ctx);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(handle) = self.decorations_future_handle.take() {
|
||||
handle.abort_handle().abort();
|
||||
}
|
||||
|
||||
let completion_session = completion_context.session.clone();
|
||||
|
||||
self.decorations_future_handle = Some(ctx.spawn_abortable(
|
||||
async move {
|
||||
(
|
||||
parse_current_commands_and_tokens(buffer_text, &completion_context)
|
||||
.await,
|
||||
completion_context,
|
||||
)
|
||||
},
|
||||
move |input, (parsed_tokens, completion_context), ctx| {
|
||||
input.last_parsed_tokens = Some(parsed_tokens);
|
||||
|
||||
if mode.ai_input_detection {
|
||||
input.run_input_mode_detection(completion_context, ctx);
|
||||
}
|
||||
|
||||
if mode.command_decoration {
|
||||
input.apply_decorations(ctx);
|
||||
}
|
||||
},
|
||||
move |_, _| {
|
||||
completion_session.cancel_active_commands();
|
||||
},
|
||||
));
|
||||
}
|
||||
CompletionSessionContext::Empty(detection_ctx) => {
|
||||
if mode.ai_input_detection {
|
||||
// No session context available (e.g., shared session viewer).
|
||||
// Use a dedicated detection context that does not expose top-level commands.
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
let ai_input_model = self.ai_input_model.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
parse_current_commands_and_tokens(buffer_text, &detection_ctx).await
|
||||
},
|
||||
move |_input, parsed_tokens, ctx| {
|
||||
ai_input_model.update(ctx, |model, ctx| {
|
||||
model.detect_and_set_input_type(
|
||||
parsed_tokens,
|
||||
EmptyCompletionContext::new(),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies error underlining and/or syntax highlighting as appropriate,
|
||||
/// using the result of the last parse operation.
|
||||
fn apply_decorations(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
if buffer_text != parsed_tokens_snapshot.buffer_text {
|
||||
// Our state is out-of-date (parsed_tokens no longer applies to the
|
||||
// updated state of the buffer) and this should be a no-op since
|
||||
// another async callback is likely handling or already handled
|
||||
// this (for a later state) i.e. race condition.
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all decorations before applying the updated ones.
|
||||
self.clear_decorations(ctx);
|
||||
|
||||
let theme = Appearance::as_ref(ctx).theme();
|
||||
let terminal_colors_normal = theme.terminal_colors().normal;
|
||||
|
||||
if self.should_show_syntax_highlighting(ctx) {
|
||||
self.apply_colors_syntax_highlighting_all_tokens(terminal_colors_normal, ctx);
|
||||
}
|
||||
if self.should_show_error_underlining(ctx) {
|
||||
self.apply_colors_error_underlining_all_tokens(terminal_colors_normal, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes decorations (error underlining, syntax highlighting, and background colors) from the input buffer.
|
||||
pub(super) fn clear_decorations(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
vec![CharOffset::from(0)..editor.buffer_size(ctx)],
|
||||
TextStyleOperation::default().clear_decorations(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies error underlining appropriately to all given tokens, given the
|
||||
/// parsed tokens data.
|
||||
///
|
||||
/// This does not unset any existing error underline decorations in the
|
||||
/// editor buffer.
|
||||
fn apply_colors_error_underlining_all_tokens(
|
||||
&mut self,
|
||||
terminal_colors_normal: AnsiColors,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut ranges = vec![];
|
||||
for token_data in &parsed_tokens_snapshot.parsed_tokens {
|
||||
if token_data.token_description.is_none()
|
||||
&& token_data.token_index == 0
|
||||
&& valid_command_for_error_underline(&token_data.token.item)
|
||||
{
|
||||
ranges.push(
|
||||
ByteOffset::from(token_data.token.span.start())
|
||||
..ByteOffset::from(token_data.token.span.end()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !ranges.is_empty() {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
ranges,
|
||||
TextStyleOperation::default().set_error_underline_color(
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into(),
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies syntax highlighting colors appropriately to all given tokens,
|
||||
/// given the parsed tokens data.
|
||||
///
|
||||
/// This does not unset any existing syntax highlighting decorations in the
|
||||
/// editor buffer.
|
||||
fn apply_colors_syntax_highlighting_all_tokens(
|
||||
&mut self,
|
||||
terminal_colors_normal: AnsiColors,
|
||||
ctx: &mut ViewContext<Input>,
|
||||
) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut ranges: HashMap<SuggestionTypeName, Vec<Range<ByteOffset>>> = HashMap::new();
|
||||
for token_data in &parsed_tokens_snapshot.parsed_tokens {
|
||||
if let Some(description) = &token_data.token_description {
|
||||
let suggestion_type = description.suggestion_type;
|
||||
let range = ByteOffset::from(token_data.token.span.start())
|
||||
..ByteOffset::from(token_data.token.span.end());
|
||||
ranges
|
||||
.entry(suggestion_type.to_name())
|
||||
.or_default()
|
||||
.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
for (suggestion_type, ranges) in ranges {
|
||||
let color: AnsiColorIdentifier = suggestion_type.into();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
ranges,
|
||||
TextStyleOperation::default()
|
||||
.set_syntax_color(color.to_ansi_color(&terminal_colors_normal).into()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "decorations_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
use warpui::{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 warp_completer::completer::SuggestionTypeName;
|
||||
|
||||
#[test]
|
||||
fn test_decorations_with_multibyte_chars() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal_colors_normal = app
|
||||
.get_singleton_model_handle::<Appearance>()
|
||||
.read(&app, |a, _| a.theme().terminal_colors().normal.to_owned());
|
||||
let error_underline_color = AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into();
|
||||
let command_color = AnsiColorIdentifier::from(SuggestionTypeName::Command)
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into();
|
||||
|
||||
let session_info = SessionInfo::new_for_test();
|
||||
let session_id = session_info.session_id;
|
||||
|
||||
let terminal =
|
||||
add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await;
|
||||
let input = terminal.read(&app, |view, _| view.input().clone());
|
||||
let editor = input.read(&app, |input, _| input.editor.clone());
|
||||
|
||||
let session_id = terminal.update(&mut app, |terminal_view, ctx| {
|
||||
terminal_view
|
||||
.sessions_model()
|
||||
.update(ctx, |sessions, _ctx| {
|
||||
// Wait until external commands have been loaded.
|
||||
let session = sessions.get(session_id).expect("session should exist");
|
||||
warpui::r#async::block_on(session.load_external_commands());
|
||||
});
|
||||
session_id
|
||||
});
|
||||
|
||||
simulate_directory_for_completion(session_id, &terminal, &mut app, "/usr/bin");
|
||||
|
||||
input
|
||||
.update(&mut app, |input, ctx| {
|
||||
input.clear_buffer_and_reset_undo_stack(ctx);
|
||||
input.user_insert("echo 'multibyte: שלום עולם'\nechoo hello world", ctx);
|
||||
|
||||
// Trigger input decoration computation instead of waiting for the
|
||||
// debounced stream to trigger.
|
||||
input.run_input_background_jobs(
|
||||
InputBackgroundJobOptions::default().with_command_decoration(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Take the future handle from the input to ensure that it doesn't
|
||||
// get cancelled by the debounced decoration update stream.
|
||||
let future_handle = input
|
||||
.decorations_future_handle
|
||||
.take()
|
||||
.expect("should have spanwed decoration task");
|
||||
ctx.await_spawned_future(future_handle.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
let text_style_runs = editor.read(&app, |editor, ctx| {
|
||||
editor
|
||||
.text_style_runs(ctx)
|
||||
.map(|run| (run.text().to_owned(), run.text_style()))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let expected = &[
|
||||
(
|
||||
"echo".to_string(),
|
||||
TextStyle::new().with_syntax_color(command_color),
|
||||
),
|
||||
(" 'multibyte: שלום עולם'\n".to_string(), Default::default()),
|
||||
(
|
||||
"echoo".to_string(),
|
||||
TextStyle::new().with_error_underline_color(error_underline_color),
|
||||
),
|
||||
(" hello world".to_string(), Default::default()),
|
||||
];
|
||||
assert_eq!(
|
||||
text_style_runs, expected,
|
||||
"---- Expected ----\n{expected:#?}\n---- Actual ----\n{text_style_runs:#?}\n"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Data source for the inline history menu, providing both conversations and commands.
|
||||
//!
|
||||
//! Ordering semantics match the legacy up-arrow history menu:
|
||||
//! - Items from different sessions appear before items from the current session
|
||||
//! - Within each group, items are sorted by timestamp (oldest first)
|
||||
//! - Commands are deduplicated, keeping the most recent occurrence
|
||||
//! - The result is that current session items appear at the bottom (closer to input)
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
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::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 ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AcceptHistoryItem {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AcceptHistoryItem {
|
||||
pub fn buffer_replacement_text(&self) -> Option<&String> {
|
||||
match self {
|
||||
AcceptHistoryItem::Command { command, .. } => Some(command),
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text),
|
||||
AcceptHistoryItem::Conversation { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptHistoryItem {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::InlineHistoryMenu;
|
||||
|
||||
fn click_behavior(&self) -> InlineMenuClickBehavior {
|
||||
match self {
|
||||
AcceptHistoryItem::Conversation { .. } => InlineMenuClickBehavior::AcceptOnClick,
|
||||
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::AIPrompt { .. } => {
|
||||
InlineMenuClickBehavior::SelectOnClick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data source that provides both live conversations for a terminal view and command history.
|
||||
pub struct InlineHistoryMenuDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
impl InlineHistoryMenuDataSource {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
agent_view_controller,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_agent_view_results(
|
||||
&self,
|
||||
query: &Query,
|
||||
prefix_match_len: usize,
|
||||
session_id: Option<SessionId>,
|
||||
app: &AppContext,
|
||||
) -> Vec<QueryResult<AcceptHistoryItem>> {
|
||||
let trimmed_query = query.text.trim();
|
||||
let include_commands =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Commands);
|
||||
let include_prompts =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::PromptHistory);
|
||||
|
||||
let history = History::handle(app).as_ref(app);
|
||||
let config = UpArrowHistoryConfig {
|
||||
include_commands,
|
||||
include_prompts,
|
||||
};
|
||||
let suggestions = history.up_arrow_suggestions_for_terminal_view(
|
||||
self.terminal_view_id,
|
||||
session_id,
|
||||
config,
|
||||
app,
|
||||
);
|
||||
|
||||
let mut results: Vec<QueryResult<AcceptHistoryItem>> = Vec::new();
|
||||
for suggestion in suggestions {
|
||||
if !trimmed_query.is_empty() && !suggestion.text().starts_with(trimmed_query) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (search_item, score) = match suggestion {
|
||||
HistoryInputSuggestion::Command { entry } => {
|
||||
let command = entry.command.trim();
|
||||
if command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let timestamp = entry.start_ts.unwrap_or_else(Local::now);
|
||||
(
|
||||
InlineHistoryItem::command(
|
||||
command.to_string(),
|
||||
entry.linked_workflow_data(),
|
||||
timestamp,
|
||||
)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
OrderedFloat(results.len() as f64),
|
||||
)
|
||||
}
|
||||
HistoryInputSuggestion::AIQuery { entry } => {
|
||||
let query_text = entry.query_text.trim();
|
||||
if query_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
InlineHistoryItem::ai_prompt(query_text.to_string(), entry.start_time)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
OrderedFloat(results.len() as f64),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
results.push(QueryResult::from(search_item.with_score(score)));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn build_conversation_entries(&self, trimmed_query: &str, app: &AppContext) -> Vec<MenuEntry> {
|
||||
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)
|
||||
{
|
||||
if conversation.is_entirely_passive() || conversation.exchange_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(timestamp) = conversation.last_modified_at() else {
|
||||
continue;
|
||||
};
|
||||
let title = conversation
|
||||
.title()
|
||||
.unwrap_or_else(|| "Untitled conversation".to_string());
|
||||
let match_result = if trimmed_query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let result = fuzzy_match::match_indices_case_insensitive(&title, trimmed_query);
|
||||
if result.is_none() || result.as_ref().is_some_and(|r| r.score < 50) {
|
||||
continue;
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
conversation_entries.push(MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: timestamp,
|
||||
item: MenuItem::Conversation {
|
||||
conversation_id: conversation.id(),
|
||||
title,
|
||||
status: conversation.status().clone(),
|
||||
match_result,
|
||||
display_timestamp: timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversation_entries.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then(a.sort_timestamp.cmp(&b.sort_timestamp))
|
||||
});
|
||||
conversation_entries
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MenuEntry {
|
||||
order: HistoryOrder,
|
||||
sort_timestamp: DateTime<Local>,
|
||||
item: MenuItem,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MenuItem {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
match_result: Option<FuzzyMatchResult>,
|
||||
display_timestamp: DateTime<Local>,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
display_timestamp: DateTime<Local>,
|
||||
prefix_match_len: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn interleave_conversations(base: Vec<MenuEntry>, conversations: Vec<MenuEntry>) -> Vec<MenuEntry> {
|
||||
let current_start_idx = base
|
||||
.iter()
|
||||
.position(|e| e.order == HistoryOrder::CurrentSession)
|
||||
.unwrap_or(base.len());
|
||||
|
||||
let mut merged: Vec<MenuEntry> = Vec::with_capacity(base.len() + conversations.len());
|
||||
merged.extend(base.iter().take(current_start_idx).cloned());
|
||||
|
||||
let base_current = base.into_iter().skip(current_start_idx).collect::<Vec<_>>();
|
||||
let mut conversations = conversations;
|
||||
conversations.sort_by(|a, b| a.sort_timestamp.cmp(&b.sort_timestamp));
|
||||
|
||||
let mut i = 0;
|
||||
for conv in conversations {
|
||||
while i < base_current.len() && base_current[i].sort_timestamp < conv.sort_timestamp {
|
||||
merged.push(base_current[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
merged.push(conv);
|
||||
}
|
||||
merged.extend(base_current.into_iter().skip(i));
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
impl SyncDataSource for InlineHistoryMenuDataSource {
|
||||
type Action = AcceptHistoryItem;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let trimmed_query = query.text.trim();
|
||||
let prefix_match_len = trimmed_query.len();
|
||||
|
||||
let session_id = self.active_session.as_ref(app).session(app).map(|s| s.id());
|
||||
let is_agent_view = self.agent_view_controller.as_ref(app).is_active();
|
||||
|
||||
if is_agent_view {
|
||||
return Ok(self.build_agent_view_results(query, prefix_match_len, session_id, app));
|
||||
}
|
||||
|
||||
let include_commands =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Commands);
|
||||
let include_conversations =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Conversations);
|
||||
|
||||
let history = History::handle(app).as_ref(app);
|
||||
let all_live_session_ids = history.all_live_session_ids();
|
||||
|
||||
let command_entries = if include_commands {
|
||||
history
|
||||
.up_arrow_suggestions_for_terminal_view(
|
||||
self.terminal_view_id,
|
||||
session_id,
|
||||
UpArrowHistoryConfig {
|
||||
include_commands: true,
|
||||
include_prompts: false,
|
||||
},
|
||||
app,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|suggestion| {
|
||||
let HistoryInputSuggestion::Command { entry } = &suggestion else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let command = entry.command.trim();
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !trimmed_query.is_empty() && !command.starts_with(trimmed_query) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let order = suggestion.history_order(session_id, &all_live_session_ids);
|
||||
let sort_timestamp = entry.start_ts.unwrap_or_default();
|
||||
let display_timestamp = entry.start_ts.unwrap_or_else(Local::now);
|
||||
|
||||
Some(MenuEntry {
|
||||
order,
|
||||
sort_timestamp,
|
||||
item: MenuItem::Command {
|
||||
command: command.to_string(),
|
||||
linked_workflow_data: entry.linked_workflow_data(),
|
||||
display_timestamp,
|
||||
prefix_match_len,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let conversation_entries = if include_conversations {
|
||||
self.build_conversation_entries(trimmed_query, app)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let merged_entries = interleave_conversations(command_entries, conversation_entries);
|
||||
|
||||
let mut results: Vec<QueryResult<AcceptHistoryItem>> = Vec::new();
|
||||
for entry in merged_entries {
|
||||
let score = OrderedFloat(results.len() as f64);
|
||||
let search_item = match entry.item {
|
||||
MenuItem::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
match_result,
|
||||
display_timestamp,
|
||||
} => InlineHistoryItem::conversation(
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
display_timestamp,
|
||||
)
|
||||
.with_name_match_result(match_result),
|
||||
MenuItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
display_timestamp,
|
||||
prefix_match_len,
|
||||
} => InlineHistoryItem::command(command, linked_workflow_data, display_timestamp)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
};
|
||||
|
||||
results.push(QueryResult::from(search_item.with_score(score)));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineHistoryMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,97 @@
|
||||
use chrono::{Local, TimeZone as _};
|
||||
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
|
||||
use super::{interleave_conversations, MenuEntry, MenuItem};
|
||||
|
||||
#[test]
|
||||
fn interleave_conversations_only_inserts_into_current_session_segment() {
|
||||
let t10 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 10).unwrap();
|
||||
let t20 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 20).unwrap();
|
||||
let t50 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 50).unwrap();
|
||||
let t100 = Local.with_ymd_and_hms(2024, 1, 1, 0, 1, 40).unwrap();
|
||||
let t150 = Local.with_ymd_and_hms(2024, 1, 1, 0, 2, 30).unwrap();
|
||||
let t200 = Local.with_ymd_and_hms(2024, 1, 1, 0, 3, 20).unwrap();
|
||||
|
||||
let base = vec![
|
||||
MenuEntry {
|
||||
order: HistoryOrder::DifferentSession,
|
||||
sort_timestamp: t10,
|
||||
item: MenuItem::Command {
|
||||
command: "d10".to_string(),
|
||||
display_timestamp: t10,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::DifferentSession,
|
||||
sort_timestamp: t20,
|
||||
item: MenuItem::Command {
|
||||
command: "d20".to_string(),
|
||||
display_timestamp: t20,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t100,
|
||||
item: MenuItem::Command {
|
||||
command: "c100".to_string(),
|
||||
display_timestamp: t100,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t200,
|
||||
item: MenuItem::Command {
|
||||
command: "c200".to_string(),
|
||||
display_timestamp: t200,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let conversations = vec![
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t150,
|
||||
item: MenuItem::Command {
|
||||
command: "conv150".to_string(),
|
||||
display_timestamp: t150,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t50,
|
||||
item: MenuItem::Command {
|
||||
command: "conv50".to_string(),
|
||||
display_timestamp: t50,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let merged = interleave_conversations(base, conversations);
|
||||
|
||||
let commands = merged
|
||||
.iter()
|
||||
.map(|e| match &e.item {
|
||||
MenuItem::Command { command, .. } => command.as_str(),
|
||||
MenuItem::Conversation { title, .. } => title.as_str(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// DifferentSession entries stay at the top; conversations are inserted into the CurrentSession segment.
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["d10", "d20", "conv50", "c100", "conv150", "c200"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Inline history menu for up-arrow history when `FeatureFlag::AgentView` is enabled.
|
||||
//!
|
||||
//! Shows both live conversations for the terminal view and command history in the terminal
|
||||
//! view, and prompts and command history in the agent view.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::AcceptHistoryItem;
|
||||
pub use view::{HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView};
|
||||
@@ -0,0 +1,285 @@
|
||||
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 ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InlineHistoryItem {
|
||||
item_type: HistoryItemType,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
prefix_match_len: usize,
|
||||
score: OrderedFloat<f64>,
|
||||
timestamp: DateTime<Local>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum HistoryItemType {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl InlineHistoryItem {
|
||||
pub fn conversation(
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
timestamp: DateTime<Local>,
|
||||
) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
},
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
timestamp: DateTime<Local>,
|
||||
) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
},
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ai_prompt(query_text: String, timestamp: DateTime<Local>) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::AIPrompt { query_text },
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_prefix_match_len(mut self, len: usize) -> Self {
|
||||
self.prefix_match_len = len;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for InlineHistoryItem {
|
||||
type Action = AcceptHistoryItem;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
let icon = match &self.item_type {
|
||||
HistoryItemType::Conversation { status, .. } => {
|
||||
render_status_element(status, icon_size, appearance)
|
||||
}
|
||||
HistoryItemType::Command { .. } => {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::Terminal.to_warpui_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(
|
||||
inline_styles::ITEM_CORNER_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
HistoryItemType::AIPrompt { .. } => {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::Prompt.to_warpui_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(
|
||||
inline_styles::ITEM_CORNER_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color =
|
||||
inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
let (display_text, match_indices, font_family) = match &self.item_type {
|
||||
HistoryItemType::Conversation { title, .. } => {
|
||||
let indices = self
|
||||
.name_match_result
|
||||
.as_ref()
|
||||
.map(|m| m.matched_indices.clone())
|
||||
.unwrap_or_default();
|
||||
(title.clone(), indices, appearance.ui_font_family())
|
||||
}
|
||||
HistoryItemType::Command { command, .. } => {
|
||||
let indices = if self.prefix_match_len > 0 {
|
||||
(0..self.prefix_match_len).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
(command.clone(), indices, appearance.monospace_font_family())
|
||||
}
|
||||
HistoryItemType::AIPrompt { query_text } => {
|
||||
let indices = if self.prefix_match_len > 0 {
|
||||
(0..self.prefix_match_len).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
(query_text.clone(), indices, appearance.ui_font_family())
|
||||
}
|
||||
};
|
||||
|
||||
let mut text = Text::new_inline(display_text, font_family, font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if !match_indices.is_empty() {
|
||||
text = text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_indices,
|
||||
);
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., text.finish()).finish());
|
||||
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(self.timestamp.to_utc()),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
match &self.item_type {
|
||||
HistoryItemType::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
..
|
||||
} => AcceptHistoryItem::Conversation {
|
||||
conversation_id: *conversation_id,
|
||||
title: title.clone(),
|
||||
},
|
||||
HistoryItemType::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => AcceptHistoryItem::Command {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
},
|
||||
HistoryItemType::AIPrompt { query_text } => AcceptHistoryItem::AIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
match &self.item_type {
|
||||
HistoryItemType::Conversation { title, .. } => format!("Conversation: {title}"),
|
||||
HistoryItemType::Command { command, .. } => format!("Command: {command}"),
|
||||
HistoryItemType::AIPrompt { query_text } => format!("AI prompt: {query_text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Inline history menu view for up-arrow history with conversations, commands and prompts.
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{AppContext, Element, Entity, EntityId, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::features::FeatureFlag;
|
||||
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::inline_history::data_source::{
|
||||
AcceptHistoryItem, InlineHistoryMenuDataSource,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuHeaderConfig, InlineMenuModel, InlineMenuPositioner,
|
||||
InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineHistoryMenuEvent {
|
||||
NavigateToConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
AcceptCommand {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AcceptAIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
SelectCommand {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
SelectAIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
/// Emitted when a conversation row becomes selected so any previously
|
||||
/// previewed command or prompt text is cleared from the input buffer.
|
||||
SelectConversation,
|
||||
NoResults,
|
||||
/// Emitted when the inline menu should be closed and additionally restore the
|
||||
/// original input buffer contents.
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Identifies a history item well enough to reselect the same logical item
|
||||
/// after rerunning the current query.
|
||||
enum HistoryItemIdentity {
|
||||
Conversation(AIConversationId),
|
||||
Command(String),
|
||||
AIPrompt(String),
|
||||
}
|
||||
|
||||
impl HistoryItemIdentity {
|
||||
fn from_item(item: &AcceptHistoryItem) -> Self {
|
||||
match item {
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
} => Self::Conversation(*conversation_id),
|
||||
AcceptHistoryItem::Command { command, .. } => Self::Command(command.clone()),
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Self::AIPrompt(query_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, item: &AcceptHistoryItem) -> bool {
|
||||
match (self, item) {
|
||||
(
|
||||
Self::Conversation(expected_id),
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
},
|
||||
) => *expected_id == *conversation_id,
|
||||
(Self::Command(expected_command), AcceptHistoryItem::Command { command, .. }) => {
|
||||
expected_command == command
|
||||
}
|
||||
(Self::AIPrompt(expected_query), AcceptHistoryItem::AIPrompt { query_text }) => {
|
||||
expected_query == query_text
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigureButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for ConfigureButtonTheme {
|
||||
fn background(&self, _hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
let theme = appearance.theme();
|
||||
if hovered {
|
||||
internal_colors::text_main(theme, theme.background())
|
||||
} else {
|
||||
internal_colors::text_sub(theme, theme.background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HistoryTab {
|
||||
All,
|
||||
Commands,
|
||||
Prompts,
|
||||
}
|
||||
|
||||
fn build_tab_configs(is_agent_view: bool) -> Vec<InlineMenuTabConfig<HistoryTab>> {
|
||||
if !is_agent_view {
|
||||
return vec![InlineMenuTabConfig {
|
||||
id: HistoryTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
}];
|
||||
}
|
||||
|
||||
vec![
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
},
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::Commands,
|
||||
label: "Commands".to_string(),
|
||||
filters: HashSet::from([QueryFilter::Commands]),
|
||||
},
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::Prompts,
|
||||
label: "Prompts".to_string(),
|
||||
filters: HashSet::from([QueryFilter::PromptHistory]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub struct InlineHistoryMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptHistoryItem, HistoryTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptHistoryItem>>,
|
||||
model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
pending_tab_switch_selection: Option<HistoryItemIdentity>,
|
||||
caller_supplied_tabs: bool,
|
||||
}
|
||||
|
||||
impl InlineHistoryMenuView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let is_agent_view = agent_view_controller.as_ref(ctx).is_active();
|
||||
let tab_configs = build_tab_configs(is_agent_view);
|
||||
Self::new_inner(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
/* caller_supplied_tabs */ false,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_tab_configs(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<HistoryTab>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self::new_inner(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
/* caller_supplied_tabs */ true,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new_inner(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<HistoryTab>>,
|
||||
caller_supplied_tabs: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| {
|
||||
InlineHistoryMenuDataSource::new(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
agent_view_controller.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptHistoryItem>::new();
|
||||
mixer.add_sync_source(
|
||||
data_source,
|
||||
[
|
||||
QueryFilter::Commands,
|
||||
QueryFilter::Conversations,
|
||||
QueryFilter::PromptHistory,
|
||||
],
|
||||
);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
let configure_button = ctx.add_view(|_| {
|
||||
ActionButton::new("Configure", ConfigureButtonTheme)
|
||||
.with_icon(Icon::Settings)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "commands history".into(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
});
|
||||
let header_config = InlineMenuHeaderConfig {
|
||||
label: "History".to_string(),
|
||||
trailing_element: Some(Box::new(move |_app: &AppContext| {
|
||||
ChildView::new(&configure_button).finish()
|
||||
})),
|
||||
};
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
input_suggestions_model,
|
||||
agent_view_controller.clone(),
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.with_header_config(header_config)
|
||||
})
|
||||
} else {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
input_suggestions_model,
|
||||
agent_view_controller.clone(),
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
};
|
||||
let model = menu_view.as_ref(ctx).model().clone();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
let suggestions_mode_model = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&agent_view_controller,
|
||||
move |me, controller, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. } => {
|
||||
// Only auto-rebuild tabs from `is_agent_view` when the
|
||||
// caller did not supply tabs explicitly. Callers that
|
||||
// pinned tabs (e.g. the cloud-mode V2 wrapper) want their
|
||||
// tab set preserved across agent-view enter/exit.
|
||||
if !me.caller_supplied_tabs {
|
||||
let is_agent_view = controller.as_ref(ctx).is_active();
|
||||
let new_configs = build_tab_configs(is_agent_view);
|
||||
me.model.update(ctx, |model, _| {
|
||||
model.set_tab_configs(new_configs);
|
||||
});
|
||||
if suggestions_mode_model.as_ref(ctx).is_inline_history_menu() {
|
||||
me.pending_tab_switch_selection = me
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_item()
|
||||
.map(HistoryItemIdentity::from_item);
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
}
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
AgentViewControllerEvent::ExitConfirmed { .. } => {}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => match item {
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::NavigateToConversation {
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::AcceptCommand {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::AIPrompt { query_text } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::AcceptAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
},
|
||||
InlineMenuEvent::SelectedItem { item } => match item {
|
||||
AcceptHistoryItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectCommand {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::AIPrompt { query_text } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::Conversation { .. } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectConversation);
|
||||
}
|
||||
},
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineHistoryMenuEvent::Close);
|
||||
}
|
||||
InlineMenuEvent::NoResults => {
|
||||
ctx.emit(InlineHistoryMenuEvent::NoResults);
|
||||
}
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.pending_tab_switch_selection = me
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_item()
|
||||
.map(HistoryItemIdentity::from_item);
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let suggestions_mode_model = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(&mixer, move |me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
if !suggestions_mode_model.as_ref(ctx).is_inline_history_menu() {
|
||||
return;
|
||||
}
|
||||
// Only tab switches stash a pending selection to restore after the
|
||||
// query reruns. For all other result updates, keep the inline menu's
|
||||
// default selection behavior.
|
||||
let Some(selection) = me.pending_tab_switch_selection.take() else {
|
||||
return;
|
||||
};
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_last_where(|item| selection.matches(item), ctx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
model,
|
||||
buffer_model,
|
||||
pending_tab_switch_selection: None,
|
||||
caller_supplied_tabs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the model handle for external use (e.g., by message bars).
|
||||
pub fn model(&self) -> &ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>> {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn render_results_only(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.menu_view.as_ref(app).render_results_only(
|
||||
/* should_render_results_in_reverse */ false, /* horizontal_padding */ 0.,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn result_count(&self, app: &AppContext) -> usize {
|
||||
self.menu_view.as_ref(app).result_count()
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
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 select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
|
||||
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();
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self.model.as_ref(ctx).active_tab_filters();
|
||||
let query_text = self.current_query_text(ctx);
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn current_query_text(&self, ctx: &ViewContext<Self>) -> String {
|
||||
// Read the query text from the mixer rather than the editor buffer. While the
|
||||
// inline history menu is open, moving the highlighted row can preview that
|
||||
// item in the editor buffer. When we rerun the search after a tab change, we
|
||||
// want to preserve the user's typed query, not the temporary preview text.
|
||||
self.mixer
|
||||
.as_ref(ctx)
|
||||
.current_query()
|
||||
.map(|q| q.text.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineHistoryMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineHistoryMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineHistoryMenuView {
|
||||
type Event = InlineHistoryMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::Element;
|
||||
use warpui::prelude::Container;
|
||||
use warpui::scene::Border;
|
||||
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::terminal::input::inline_menu::model::InlineMenuModel;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuAction, InlineMenuMessageProvider, InlineMenuPositioner,
|
||||
};
|
||||
use crate::terminal::input::message_bar::common::render_standard_message_bar;
|
||||
use crate::terminal::input::message_bar::{EmptyMessageProducer, MessageProvider};
|
||||
|
||||
pub struct InlineMenuMessageBarArgs<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
|
||||
pub inline_menu_model: ModelHandle<InlineMenuModel<A, T>>,
|
||||
pub agent_view_controller: ModelHandle<AgentViewController>,
|
||||
pub positioner: ModelHandle<InlineMenuPositioner>,
|
||||
}
|
||||
|
||||
/// Renders contextual hint text at the bottom of the agent view status bar.
|
||||
pub struct InlineMenuMessageBar<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
|
||||
inline_menu_model: ModelHandle<InlineMenuModel<A, T>>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: ModelHandle<InlineMenuPositioner>,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> Entity for InlineMenuMessageBar<A, T> {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuMessageBar<A, T> {
|
||||
pub fn new(args: InlineMenuMessageBarArgs<A, T>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let InlineMenuMessageBarArgs {
|
||||
inline_menu_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
} = args;
|
||||
|
||||
ctx.subscribe_to_model(&inline_menu_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. } => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
|
||||
Self {
|
||||
inline_menu_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const INLINE_MENU_BORDER_WIDTH: f32 = 1.;
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> View for InlineMenuMessageBar<A, T> {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineMenuMessageBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let inline_menu_model = self.inline_menu_model.as_ref(app);
|
||||
|
||||
let message = InlineMenuMessageProvider::<A>::default()
|
||||
.produce_message(InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
})
|
||||
.or_else(|| {
|
||||
EmptyMessageProducer.produce_message(InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
})
|
||||
})
|
||||
.expect("Empty message producer always returns Some().");
|
||||
|
||||
let message_bar = render_standard_message_bar(message, None, app);
|
||||
if !self.agent_view_controller.as_ref(app).is_active() {
|
||||
let is_rendering_below_input = self
|
||||
.positioner
|
||||
.as_ref(app)
|
||||
.should_render_inline_menu_below_input();
|
||||
Container::new(message_bar)
|
||||
.with_border(
|
||||
Border::new(INLINE_MENU_BORDER_WIDTH)
|
||||
.with_sides(
|
||||
is_rendering_below_input,
|
||||
false,
|
||||
!is_rendering_below_input,
|
||||
false,
|
||||
)
|
||||
.with_border_fill(Appearance::as_ref(app).theme().outline()),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
message_bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for inline menu message producers.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct InlineMenuMessageArgs<'a, A: InlineMenuAction, T = ()> {
|
||||
pub inline_menu_model: &'a InlineMenuModel<A, T>,
|
||||
pub app: &'a AppContext,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use warpui::{keymap::Keystroke, AppContext};
|
||||
|
||||
use crate::editor::{SELECT_DOWN_ACTION_NAME, SELECT_UP_ACTION_NAME};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem, MessageProvider};
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
|
||||
/// Generic message provider for inline menus.
|
||||
///
|
||||
/// The message line is what contains the up/down navigation hints and optionally,
|
||||
/// contextual CTAs based on the currently selected item.
|
||||
pub struct InlineMenuMessageProvider<A>(PhantomData<A>);
|
||||
|
||||
impl<A> Default for InlineMenuMessageProvider<A> {
|
||||
fn default() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, T> MessageProvider<InlineMenuMessageArgs<'_, A, T>> for InlineMenuMessageProvider<A>
|
||||
where
|
||||
A: InlineMenuAction,
|
||||
{
|
||||
fn produce_message(&self, args: InlineMenuMessageArgs<'_, A, T>) -> Option<Message> {
|
||||
A::produce_inline_menu_message(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default set of navigation/dismiss message items that should be rendered in all
|
||||
/// inline menus.
|
||||
pub fn default_navigation_message_items<A: InlineMenuAction, T>(
|
||||
args: &InlineMenuMessageArgs<'_, A, T>,
|
||||
) -> Vec<MessageItem> {
|
||||
let navigation_keystrokes = navigation_keystrokes(args.app);
|
||||
let mut items = vec![
|
||||
MessageItem::keystroke(navigation_keystrokes.0),
|
||||
MessageItem::keystroke(navigation_keystrokes.1),
|
||||
MessageItem::text(" to navigate"),
|
||||
];
|
||||
|
||||
if args.inline_menu_model.tab_configs().len() > 1 {
|
||||
items.push(MessageItem::keystroke(Keystroke {
|
||||
key: "tab".to_owned(),
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}));
|
||||
items.push(MessageItem::text(" to cycle tabs"));
|
||||
}
|
||||
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to dismiss"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::<A>::Dismiss);
|
||||
},
|
||||
args.inline_menu_model.mouse_states().dismiss.clone(),
|
||||
));
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn navigation_keystrokes(app: &AppContext) -> (Keystroke, Keystroke) {
|
||||
static DEFAULT_SELECT_UP_BINDING: LazyLock<Option<Keystroke>> = LazyLock::new(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(Keystroke {
|
||||
key: "P".to_owned(),
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
static DEFAULT_SELECT_DOWN_BINDING: LazyLock<Option<Keystroke>> = LazyLock::new(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(Keystroke {
|
||||
key: "N".to_owned(),
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((up_keystroke, down_keystroke)) =
|
||||
keybinding_name_to_keystroke(SELECT_UP_ACTION_NAME, app)
|
||||
.zip(keybinding_name_to_keystroke(SELECT_DOWN_ACTION_NAME, app))
|
||||
.filter(|(up_binding, down_binding)| {
|
||||
Some(up_binding) != DEFAULT_SELECT_UP_BINDING.as_ref()
|
||||
&& Some(down_binding) != DEFAULT_SELECT_DOWN_BINDING.as_ref()
|
||||
})
|
||||
{
|
||||
(up_keystroke, down_keystroke)
|
||||
} else {
|
||||
(
|
||||
Keystroke {
|
||||
key: "up".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Keystroke {
|
||||
key: "down".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Generic inline menu view for rendering search results with selection and navigation.
|
||||
mod message_bar;
|
||||
mod message_provider;
|
||||
mod model;
|
||||
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;
|
||||
pub use view::{
|
||||
DetailsRenderConfig, InlineMenuAction, InlineMenuClickBehavior, InlineMenuEvent,
|
||||
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView,
|
||||
};
|
||||
|
||||
/// Identifies a specific inline menu type.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Identifies a specific inline menu.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum InlineMenuType {
|
||||
SlashCommands,
|
||||
ModelSelector,
|
||||
ConversationMenu,
|
||||
ProfileSelector,
|
||||
PromptsMenu,
|
||||
SkillMenu,
|
||||
UserQueryMenu,
|
||||
RewindMenu,
|
||||
InlineHistoryMenu,
|
||||
IndexedReposMenu,
|
||||
PlanMenu,
|
||||
}
|
||||
|
||||
impl InlineMenuType {
|
||||
fn display_label(&self) -> &'static str {
|
||||
match self {
|
||||
InlineMenuType::SlashCommands => "/Commands",
|
||||
InlineMenuType::ModelSelector => "/Model",
|
||||
InlineMenuType::ConversationMenu => "/Conversations",
|
||||
InlineMenuType::ProfileSelector => "/Profiles",
|
||||
InlineMenuType::PromptsMenu => "/Prompts",
|
||||
InlineMenuType::SkillMenu => "/Skills",
|
||||
InlineMenuType::UserQueryMenu => "/Fork",
|
||||
InlineMenuType::RewindMenu => "/Rewind",
|
||||
InlineMenuType::InlineHistoryMenu => "History",
|
||||
InlineMenuType::IndexedReposMenu => "/Repos",
|
||||
InlineMenuType::PlanMenu => "/Plans",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_suggestions_mode(mode: &InputSuggestionsMode) -> Option<Self> {
|
||||
match mode {
|
||||
InputSuggestionsMode::SlashCommands => Some(InlineMenuType::SlashCommands),
|
||||
InputSuggestionsMode::ModelSelector => Some(InlineMenuType::ModelSelector),
|
||||
InputSuggestionsMode::ConversationMenu => Some(InlineMenuType::ConversationMenu),
|
||||
InputSuggestionsMode::ProfileSelector => Some(InlineMenuType::ProfileSelector),
|
||||
InputSuggestionsMode::PromptsMenu => Some(InlineMenuType::PromptsMenu),
|
||||
InputSuggestionsMode::SkillMenu => Some(InlineMenuType::SkillMenu),
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: UserQueryMenuAction::ForkFrom,
|
||||
..
|
||||
} => Some(InlineMenuType::UserQueryMenu),
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: UserQueryMenuAction::Rewind,
|
||||
..
|
||||
} => Some(InlineMenuType::RewindMenu),
|
||||
InputSuggestionsMode::InlineHistoryMenu { .. } => {
|
||||
Some(InlineMenuType::InlineHistoryMenu)
|
||||
}
|
||||
InputSuggestionsMode::IndexedReposMenu => Some(InlineMenuType::IndexedReposMenu),
|
||||
InputSuggestionsMode::PlanMenu { .. } => Some(InlineMenuType::PlanMenu),
|
||||
InputSuggestionsMode::Closed
|
||||
| InputSuggestionsMode::HistoryUp { .. }
|
||||
| InputSuggestionsMode::CompletionSuggestions { .. }
|
||||
| InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
|
||||
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
|
||||
| InputSuggestionsMode::AIContextMenu { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Generic model for tracking the selected item in an inline menu.
|
||||
use std::collections::HashSet;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::inline_menu::view::InlineMenuAction;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InlineMenuMouseStates {
|
||||
pub accept: MouseStateHandle,
|
||||
pub accept_secondary: MouseStateHandle,
|
||||
pub dismiss: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Configuration for a single tab in the inline menu.
|
||||
///
|
||||
/// Each tab has a label (displayed in the header) and a set of query filters
|
||||
/// that are applied when the tab is active.
|
||||
#[derive(Clone)]
|
||||
pub struct InlineMenuTabConfig<T = ()> {
|
||||
pub id: T,
|
||||
pub label: String,
|
||||
pub filters: HashSet<QueryFilter>,
|
||||
}
|
||||
|
||||
/// This model is generic over the action type `A` (the same type used by the `SearchMixer`).
|
||||
/// It serves as a denormalized copy of the selected item that can be read/subscribed to
|
||||
/// without requiring a strict dependency on the inline menu view itself. It also tracks
|
||||
/// the inline menu's selected tab (if any).
|
||||
pub struct InlineMenuModel<A: InlineMenuAction, T = ()> {
|
||||
selected_item: Option<A>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<T>>,
|
||||
active_tab_index: usize,
|
||||
mouse_states: InlineMenuMouseStates,
|
||||
tab_mouse_states: Vec<MouseStateHandle>,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> Default for InlineMenuModel<A> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> InlineMenuModel<A> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selected_item: None,
|
||||
tab_configs: Vec::new(),
|
||||
active_tab_index: 0,
|
||||
mouse_states: InlineMenuMouseStates::default(),
|
||||
tab_mouse_states: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: Clone + PartialEq> InlineMenuModel<A, T> {
|
||||
pub fn new_with_tabs(tab_configs: Vec<InlineMenuTabConfig<T>>, initial_tab: Option<T>) -> Self {
|
||||
let tab_mouse_states = tab_configs
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
let active_tab_index = initial_tab
|
||||
.and_then(|id| tab_configs.iter().position(|config| config.id == id))
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
selected_item: None,
|
||||
tab_configs,
|
||||
active_tab_index,
|
||||
mouse_states: InlineMenuMouseStates::default(),
|
||||
tab_mouse_states,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_tab_id(&self) -> Option<T> {
|
||||
self.tab_configs
|
||||
.get(self.active_tab_index)
|
||||
.map(|config| config.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T> InlineMenuModel<A, T> {
|
||||
/// Returns a reference to the currently selected item, if any.
|
||||
pub fn selected_item(&self) -> Option<&A> {
|
||||
self.selected_item.as_ref()
|
||||
}
|
||||
|
||||
pub fn tab_configs(&self) -> &[InlineMenuTabConfig<T>] {
|
||||
&self.tab_configs
|
||||
}
|
||||
|
||||
pub fn active_tab_index(&self) -> usize {
|
||||
self.active_tab_index
|
||||
}
|
||||
|
||||
pub fn active_tab_filters(&self) -> HashSet<QueryFilter> {
|
||||
self.tab_configs
|
||||
.get(self.active_tab_index)
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mouse_states(&self) -> &InlineMenuMouseStates {
|
||||
&self.mouse_states
|
||||
}
|
||||
|
||||
pub fn tab_mouse_states(&self) -> &[MouseStateHandle] {
|
||||
&self.tab_mouse_states
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: Send + Sync + 'static> InlineMenuModel<A, T> {
|
||||
pub fn set_tab_configs(&mut self, tab_configs: Vec<InlineMenuTabConfig<T>>) {
|
||||
if self.tab_mouse_states.len() < tab_configs.len() {
|
||||
self.tab_mouse_states
|
||||
.resize_with(tab_configs.len(), MouseStateHandle::default);
|
||||
}
|
||||
self.tab_configs = tab_configs;
|
||||
if self.active_tab_index >= self.tab_configs.len() {
|
||||
self.active_tab_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_active_tab_index(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
|
||||
if index != self.active_tab_index && index < self.tab_configs.len() {
|
||||
self.active_tab_index = index;
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedActiveTab);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_selected_item(&mut self, item: A, ctx: &mut ModelContext<Self>) {
|
||||
self.selected_item = Some(item);
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedSelectedItem);
|
||||
}
|
||||
|
||||
pub(super) fn clear_selected_item(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.selected_item.is_some() {
|
||||
self.selected_item = None;
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedSelectedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineMenuModelEvent {
|
||||
UpdatedSelectedItem,
|
||||
UpdatedActiveTab,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> Entity for InlineMenuModel<A, T> {
|
||||
type Event = InlineMenuModelEvent;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
|
||||
use settings::Setting as _;
|
||||
use std::collections::HashMap;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
||||
};
|
||||
|
||||
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 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,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_VISIBLE_RESULT_COUNT: f32 = 9.;
|
||||
const MIN_VISIBLE_RESULT_COUNT: f32 = 3.;
|
||||
const MAX_VISIBLE_RESULT_COUNT: f32 = 20.;
|
||||
|
||||
/// Returns the pixel height of the content panel for a given number of visible result rows,
|
||||
/// including vertical padding and border widths.
|
||||
fn content_height_for_row_count(count: f32, appearance: &Appearance) -> f32 {
|
||||
(QUERY_RESULT_RENDERER_STYLES.result_item_height_fn)(appearance) * count
|
||||
+ (CONTENT_VERTICAL_PADDING * 2.)
|
||||
+ (CONTENT_BORDER_WIDTH * 2.)
|
||||
}
|
||||
|
||||
/// Owns the positioning and sizing state for all inline menus.
|
||||
///
|
||||
/// Computes whether the menu renders above or below the input — which depends on the user's
|
||||
/// `InputMode` setting and the available viewport space — and acts as the sole source of truth
|
||||
/// for per-menu content heights. The view never stores height; it reads from and writes to
|
||||
/// this positioner exclusively.
|
||||
pub struct InlineMenuPositioner {
|
||||
size_info: SizeInfo,
|
||||
terminal_content_position_id: String,
|
||||
/// The save position ID for the input box (prompt, editor, message bar, padding)
|
||||
/// WITHOUT the inline menu. Used to measure the input height from the previous frame
|
||||
/// for pane-aware height capping.
|
||||
input_save_position_id: String,
|
||||
window_id: WindowId,
|
||||
should_render_below_input: bool,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
/// Per-menu custom content heights, set by resize.
|
||||
custom_content_heights: HashMap<InlineMenuType, f32>,
|
||||
}
|
||||
|
||||
impl InlineMenuPositioner {
|
||||
pub fn new(
|
||||
suggestions_mode_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: &ModelHandle<AgentViewController>,
|
||||
terminal_content_position_id: String,
|
||||
input_save_position_id: String,
|
||||
size_info: SizeInfo,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let persisted_heights = InputSettings::as_ref(ctx)
|
||||
.inline_menu_custom_content_heights
|
||||
.value()
|
||||
.clone();
|
||||
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() {
|
||||
me.should_render_below_input = false;
|
||||
} else {
|
||||
match *InputModeSettings::as_ref(ctx).input_mode {
|
||||
InputMode::PinnedToBottom => {
|
||||
me.should_render_below_input = false;
|
||||
}
|
||||
InputMode::PinnedToTop => me.should_render_below_input = true,
|
||||
InputMode::Waterfall => {
|
||||
let terminal_view_height = element_size_at_last_frame(
|
||||
&me.terminal_content_position_id,
|
||||
me.window_id,
|
||||
ctx,
|
||||
)
|
||||
.map(|size| size.y())
|
||||
.unwrap_or(me.size_info.pane_height_px().as_f32());
|
||||
|
||||
me.should_render_below_input = terminal_view_height
|
||||
< me.inline_menu_height(ctx) + me.menu_frame_height(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.emit(Updated::Repositioned);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
size_info,
|
||||
terminal_content_position_id,
|
||||
input_save_position_id,
|
||||
window_id,
|
||||
suggestions_mode_model: suggestions_mode_model.clone(),
|
||||
agent_view_controller: agent_view_controller.clone(),
|
||||
should_render_below_input: false,
|
||||
custom_content_heights: persisted_heights,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the inline menu should be rendered below the input, factoring in the
|
||||
/// current input position in the viewport and the available space above/below it.
|
||||
///
|
||||
/// The preference is always to render the menu above the input, if there is available space.
|
||||
pub fn should_render_inline_menu_below_input(&self) -> bool {
|
||||
self.should_render_below_input
|
||||
}
|
||||
|
||||
/// Returns `true` if the inline menu should render results in reverse.
|
||||
///
|
||||
/// If `true`, the 0-index item in the menu is rendered at the bottom of the menu instead of the
|
||||
/// top.
|
||||
pub(super) fn should_render_results_in_reverse(&self, app: &AppContext) -> bool {
|
||||
// History menu should not reverse results - up-arrow history is always cycled
|
||||
// using the up arrow key, so the ordering should be consistent.
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.should_render_below_input
|
||||
}
|
||||
|
||||
/// Returns an 'inset' to be applied to the y position of the top of the blocklist element when:
|
||||
/// * in waterfall mode, which is the only mode in which the input may be rendered in a floating
|
||||
/// position between the top and bottom of the viewport.
|
||||
/// * the inline menu is visible
|
||||
/// * the menu is rendered above the input
|
||||
///
|
||||
/// Applying this inset to the blocklist element's position simulates 'scrolling' the blocklist
|
||||
/// downwards by the height of the inline menu. Effectively, this "slides" blocklist content
|
||||
/// upwards when the menu is open.
|
||||
///
|
||||
/// This inset application is done in one of two ways in waterfall mode:
|
||||
/// * If there is no gap, we subtract the 'inset', equivalent to the inline menu
|
||||
/// height from the max height constraint of the blocklist element
|
||||
/// * If there _is_ a gap, we subtract the inset from painted visible blocklist element originj
|
||||
/// at paint time.
|
||||
///
|
||||
/// When there is no gap, that's pretty much all we need to do.
|
||||
///
|
||||
/// When there is a gap, there is further accounting to be done: this blocklist translation is
|
||||
/// applied at paint-time to reduce the surface area of logic that needs to be aware of the
|
||||
/// inline menu visiblity/height (for example, the sumtree doesn't need to be aware of this at
|
||||
/// all). This means, however, we do need to account for inset value in logic that translates
|
||||
/// artifacts of rendering (layout/paint) back to the data model -- main in places where the
|
||||
/// sumtree heights are dependent on the laid out input size (the inline menu is part of the
|
||||
/// input element subtree). The main areas considered are:
|
||||
///
|
||||
/// * Gap height calculation (which is one of the items in the sumtree)
|
||||
/// * Scroll position
|
||||
///
|
||||
/// Both of these calculations depend on the laid out input size, which must account for the
|
||||
/// visible inline menu height.
|
||||
///
|
||||
/// Here there be dragons - the key 'contract' of this method is that it returns the exact
|
||||
/// height of the inline menu when it is open and rendered above the input.
|
||||
///
|
||||
/// If and how the blocklist element logic uses this value depends on whether or not it's
|
||||
/// in waterfall mode and whether or not there is an active gap.
|
||||
pub(in crate::terminal) fn blocklist_top_inset_when_in_waterfall_mode(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<Pixels> {
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_menu_open()
|
||||
{
|
||||
(!self.should_render_below_input)
|
||||
.then(|| (self.inline_menu_height(app) + self.menu_frame_height(app)).into_pixels())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when we should skip rendering the menu entirely because the pane is too short
|
||||
/// (otherwise we run into weird clipping and overflow bugs).
|
||||
pub fn should_hide_inline_menu_for_pane_size(&self, app: &AppContext) -> bool {
|
||||
let one_row_height = content_height_for_row_count(0., Appearance::as_ref(app));
|
||||
self.max_content_height_for_pane(app) < one_row_height
|
||||
}
|
||||
|
||||
/// Returns the content panel height for the currently active menu, or the default
|
||||
/// height if no custom height has been set via resize.
|
||||
///
|
||||
/// The returned height is capped so the total menu container plus the non-menu input
|
||||
/// elements do not exceed the pane height.
|
||||
pub fn inline_menu_height(&self, app: &AppContext) -> f32 {
|
||||
self.base_content_height(app)
|
||||
.min(self.max_content_height_for_pane(app))
|
||||
}
|
||||
|
||||
/// The user-customized or default content height, without any pane-aware capping.
|
||||
fn base_content_height(&self, app: &AppContext) -> f32 {
|
||||
self.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.inline_menu_type()
|
||||
.and_then(|mt| self.custom_content_heights.get(&mt).copied())
|
||||
.unwrap_or_else(|| {
|
||||
content_height_for_row_count(DEFAULT_VISIBLE_RESULT_COUNT, Appearance::as_ref(app))
|
||||
})
|
||||
}
|
||||
|
||||
/// The non-content height of the menu container (header, message bar, borders).
|
||||
fn menu_frame_height(&self, app: &AppContext) -> f32 {
|
||||
let header_height = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
HEADER_ROW_HEIGHT + HEADER_BORDER * 2.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
if self.agent_view_controller.as_ref(app).is_active() {
|
||||
header_height
|
||||
} else {
|
||||
header_height + standard_message_bar_height(app) + INLINE_MENU_BORDER_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resize delta to the given menu type's content height, clamping to the
|
||||
/// allowed min/max range. Returns the actual height change applied, or `None` if the
|
||||
/// delta had no effect.
|
||||
pub fn apply_resize_delta(
|
||||
&mut self,
|
||||
menu_type: InlineMenuType,
|
||||
delta: f32,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Option<f32> {
|
||||
let current = self.inline_menu_height(ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let min_height = content_height_for_row_count(MIN_VISIBLE_RESULT_COUNT, appearance);
|
||||
let max_height = content_height_for_row_count(MAX_VISIBLE_RESULT_COUNT, appearance)
|
||||
.min(self.max_content_height_for_pane(ctx))
|
||||
.max(min_height);
|
||||
|
||||
// When the menu renders above input, the header is at the top — dragging
|
||||
// down (positive delta) shrinks the menu. When below, the header is at
|
||||
// the bottom — dragging down grows the menu.
|
||||
let new_height = if self.should_render_below_input {
|
||||
current + delta
|
||||
} else {
|
||||
current - delta
|
||||
}
|
||||
.clamp(min_height, max_height);
|
||||
let height_change = new_height - current;
|
||||
if height_change.abs() <= f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.custom_content_heights.insert(menu_type, new_height);
|
||||
|
||||
ctx.emit(Updated::Resized);
|
||||
Some(height_change)
|
||||
}
|
||||
|
||||
/// Persists the current custom content heights to settings.
|
||||
/// Call this when a resize drag finishes rather than on every drag event.
|
||||
pub fn persist_custom_content_heights(&self, ctx: &mut ModelContext<Self>) {
|
||||
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings
|
||||
.inline_menu_custom_content_heights
|
||||
.set_value(self.custom_content_heights.clone(), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates the cached pane size info.
|
||||
pub fn set_size_info(&mut self, size_info: SizeInfo, ctx: &mut ModelContext<Self>) {
|
||||
let height_changed =
|
||||
(self.size_info.pane_height_px().as_f32() - size_info.pane_height_px().as_f32()).abs()
|
||||
> f32::EPSILON;
|
||||
self.size_info = size_info;
|
||||
if height_changed {
|
||||
ctx.emit(Updated::Resized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximum content panel height that keeps the entire menu container
|
||||
/// plus the non-menu input elements within the pane height.
|
||||
fn max_content_height_for_pane(&self, app: &AppContext) -> f32 {
|
||||
let pane_height = self.size_info.pane_height_px().as_f32();
|
||||
let overhead = self.menu_frame_height(app);
|
||||
|
||||
let input_box_height =
|
||||
element_size_at_last_frame(&self.input_save_position_id, self.window_id, app)
|
||||
.map(|size| size.y())
|
||||
.unwrap_or(0.);
|
||||
|
||||
// We clamp to 0 rather than to MIN_VISIBLE_RESULT_COUNT so that, if the pane is too
|
||||
// short to fit even a minimal menu, we still let the menu height shrink to nothing
|
||||
// rather than overflowing into an adjacent pane.
|
||||
(pane_height - input_box_height - overhead).max(0.)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Updated {
|
||||
Repositioned,
|
||||
Resized,
|
||||
}
|
||||
|
||||
impl Entity for InlineMenuPositioner {
|
||||
type Event = Updated;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Shared styling utilities for inline menu implementations.
|
||||
//!
|
||||
//! This module provides common styling functions used across all inline menu
|
||||
//! implementations (models, slash commands, conversations) to ensure consistent
|
||||
//! visual design matching the Figma specifications.
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::color::blend::Blend;
|
||||
use warp_core::ui::theme::{Fill, WarpTheme};
|
||||
use warpui::color::ColorU;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_fill;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
/// Font size used for inline menu items.
|
||||
pub fn font_size(appearance: &Appearance) -> f32 {
|
||||
appearance.monospace_font_size() - 2.
|
||||
}
|
||||
|
||||
pub const ICON_MARGIN: f32 = 8.0;
|
||||
pub const ITEM_HORIZONTAL_PADDING: f32 = 8.0;
|
||||
pub const ITEM_CORNER_RADIUS: f32 = 4.0;
|
||||
pub const CONTENT_VERTICAL_PADDING: f32 = 8.;
|
||||
pub const CONTENT_BORDER_WIDTH: f32 = 1.;
|
||||
|
||||
/// Height of the header row content, ensuring all headers render at the same
|
||||
/// height regardless of whether tabs or trailing elements are present.
|
||||
pub const HEADER_ROW_HEIGHT: f32 = 24.;
|
||||
pub const HEADER_BORDER: f32 = 1.;
|
||||
|
||||
pub fn menu_background_color(app: &AppContext) -> ColorU {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
theme.background().blend(&agent_view_bg_fill(app)).into()
|
||||
}
|
||||
|
||||
pub fn item_background(
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
let theme = appearance.theme();
|
||||
match highlight_state {
|
||||
ItemHighlightState::Selected { .. } => Some(theme.surface_overlay_2()),
|
||||
ItemHighlightState::Hovered => Some(theme.surface_overlay_1()),
|
||||
ItemHighlightState::Default => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.main_text_color(background)
|
||||
}
|
||||
|
||||
pub fn secondary_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.sub_text_color(background)
|
||||
}
|
||||
|
||||
pub fn disabled_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.disabled_text_color(background)
|
||||
}
|
||||
|
||||
pub fn icon_color(appearance: &Appearance) -> Fill {
|
||||
let theme = appearance.theme();
|
||||
theme.sub_text_color(theme.background()).with_opacity(80)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
//! Shared message producers for displaying attached blocks/text context.
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::ai::blocklist::agent_view::{AgentMessageBarMouseStates, AgentViewController};
|
||||
use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputModel};
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::message_bar::{
|
||||
truncated_command_for_block, Message, MessageItem, MessageProvider,
|
||||
};
|
||||
use crate::terminal::input::InputAction;
|
||||
use crate::terminal::model::TerminalModel;
|
||||
|
||||
/// Trait for message args that can provide attached context information.
|
||||
/// Exposes the required dependencies for attached context message producers.
|
||||
pub trait AttachedContextArgs {
|
||||
fn terminal_model(&self) -> &TerminalModel;
|
||||
fn input_buffer_model(&self) -> &InputBufferModel;
|
||||
fn input_model(&self) -> &BlocklistAIInputModel;
|
||||
fn agent_view_controller(&self) -> &AgentViewController;
|
||||
fn context_model(&self) -> &BlocklistAIContextModel;
|
||||
fn mouse_states(&self) -> &AgentMessageBarMouseStates;
|
||||
}
|
||||
|
||||
/// Produces a message when blocks or selected text are attached as context.
|
||||
pub struct AttachedBlocksMessageProducer;
|
||||
|
||||
impl<Args: AttachedContextArgs + Copy> MessageProvider<Args> for AttachedBlocksMessageProducer {
|
||||
fn produce_message(&self, args: Args) -> Option<Message> {
|
||||
// When AgentViewBlockContext is enabled, user-executed blocks are auto-attached
|
||||
// as context, so we don't need to show this message.
|
||||
if FeatureFlag::AgentViewBlockContext.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// In the agent view, only show the attached context message if in AI mode.
|
||||
if args.agent_view_controller().is_active()
|
||||
&& !args.input_buffer_model().current_value().is_empty()
|
||||
&& !args.input_model().is_ai_input_enabled()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let context_block_ids = args.context_model().pending_context_block_ids();
|
||||
if context_block_ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let block_command = context_block_ids
|
||||
.iter()
|
||||
.find_map(|id| {
|
||||
args.terminal_model()
|
||||
.block_list()
|
||||
.block_with_id(id)
|
||||
.map(|block| block.command_to_string())
|
||||
})
|
||||
.map(|cmd| truncated_command_for_block(&cmd))?;
|
||||
|
||||
let message_text = if context_block_ids.len() == 1 {
|
||||
format!("`{}` attached as context", block_command)
|
||||
} else if context_block_ids.len() == 2 {
|
||||
format!(
|
||||
"`{}` and 1 other command attached as context",
|
||||
block_command
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"`{}` and {} other commands attached as context",
|
||||
block_command,
|
||||
context_block_ids.len().saturating_sub(1)
|
||||
)
|
||||
};
|
||||
|
||||
let mut items = vec![MessageItem::text(message_text)];
|
||||
|
||||
// Always show ESC hint in agent view, make it clickable
|
||||
if args.agent_view_controller().is_active() {
|
||||
items.push(MessageItem::text(", "));
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to remove"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ClearAttachedContext);
|
||||
},
|
||||
args.mouse_states().clear_attached_context.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a message when text selection is attached as context.
|
||||
pub struct AttachedTextSelectionMessageProducer;
|
||||
|
||||
impl<Args: AttachedContextArgs + Copy> MessageProvider<Args>
|
||||
for AttachedTextSelectionMessageProducer
|
||||
{
|
||||
fn produce_message(&self, args: Args) -> Option<Message> {
|
||||
// Only apply the visibility condition when agent view is active.
|
||||
// When inactive, always show the message.
|
||||
if args.agent_view_controller().is_active()
|
||||
&& !args.input_buffer_model().current_value().is_empty()
|
||||
&& !args.input_model().is_ai_input_enabled()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only show if there's selected text and no blocks attached
|
||||
// (blocks take precedence per requirements)
|
||||
if !args.context_model().pending_context_block_ids().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let _ = args.context_model().pending_context_selected_text()?;
|
||||
|
||||
let mut items = vec![MessageItem::text("selected text attached as context")];
|
||||
|
||||
// Always show ESC hint in agent view, make it clickable
|
||||
if args.agent_view_controller().is_active() {
|
||||
items.push(MessageItem::text(", "));
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to remove"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ClearAttachedContext);
|
||||
},
|
||||
args.mouse_states().clear_attached_context.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_color;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{
|
||||
Border, CacheOption, Clipped, Container, CornerRadius, Element, Hoverable, Image,
|
||||
ParentElement, Radius,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, Text};
|
||||
use warpui::ui_components::keyboard_shortcut::keystroke_to_keys;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
|
||||
use crate::terminal;
|
||||
use crate::terminal::input::message_bar::{ChipHorizontalAlignment, Message, MessageItem};
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn standard_message_bar_height(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
app.font_cache()
|
||||
.line_height(styles::font_size(app), appearance.line_height_ratio())
|
||||
+ styles::VERTICAL_PADDING * 2.
|
||||
}
|
||||
|
||||
pub fn render_standard_message_bar(
|
||||
message: Message,
|
||||
right_element: Option<Box<dyn Element>>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use warpui::prelude::{MainAxisAlignment, MainAxisSize};
|
||||
|
||||
let (left_items, right_chips): (Vec<_>, Vec<_>) = message.items.into_iter().partition(|item| {
|
||||
!matches!(
|
||||
item,
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment: ChipHorizontalAlignment::Right,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
let right_element = if right_chips.is_empty() {
|
||||
right_element
|
||||
} else {
|
||||
let right_chips_element = render_message_bar_items(&right_chips, app);
|
||||
Some(if let Some(existing_right) = right_element {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(right_chips_element)
|
||||
.with_child(existing_right)
|
||||
.finish()
|
||||
} else {
|
||||
right_chips_element
|
||||
})
|
||||
};
|
||||
|
||||
let content = if let Some(right_element) = right_element {
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(render_message_bar_items(&left_items, app))
|
||||
.with_child(right_element)
|
||||
.finish()
|
||||
} else {
|
||||
Align::new(render_message_bar_items(&left_items, app))
|
||||
.left()
|
||||
.finish()
|
||||
};
|
||||
|
||||
ConstrainedBox::new(
|
||||
Clipped::new(
|
||||
Container::new(content)
|
||||
.with_horizontal_padding(*terminal::view::PADDING_LEFT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(standard_message_bar_height(app))
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_standard_message(message: Message, app: &AppContext) -> Box<dyn Element> {
|
||||
render_message_bar_items(&message.items, app)
|
||||
}
|
||||
|
||||
/// Renders a sequence of message items into a flex row.
|
||||
/// Currently used for agent message bar items and zero state message bar items.
|
||||
fn render_message_bar_items(items: &[MessageItem], app: &AppContext) -> Box<dyn Element> {
|
||||
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);
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let mut child: Box<dyn Element> = match item {
|
||||
MessageItem::Keystroke {
|
||||
keystroke,
|
||||
color,
|
||||
background_color,
|
||||
} => Container::new(render_keystroke_with_color_overrides(
|
||||
keystroke,
|
||||
*color,
|
||||
*background_color,
|
||||
app,
|
||||
))
|
||||
.finish(),
|
||||
MessageItem::Text { content, color } => {
|
||||
let font_color = color.unwrap_or(default_font_color);
|
||||
Text::new(
|
||||
content.clone(),
|
||||
appearance.ui_font_family(),
|
||||
styles::font_size(app),
|
||||
)
|
||||
.with_color(font_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Hyperlink {
|
||||
content,
|
||||
url,
|
||||
color,
|
||||
mouse_state,
|
||||
} => {
|
||||
let link_color = color.unwrap_or_else(|| appearance.theme().accent().into());
|
||||
let content = content.clone();
|
||||
let url = url.clone();
|
||||
Hoverable::new(mouse_state.clone(), |_| {
|
||||
Text::new(
|
||||
content.clone(),
|
||||
appearance.ui_font_family(),
|
||||
styles::font_size(app),
|
||||
)
|
||||
.with_color(link_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |_, app, _| {
|
||||
app.open_url(&url);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Icon { icon, color } => {
|
||||
let icon_color = color.unwrap_or(default_font_color);
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(styles::font_size(app))
|
||||
.with_width(styles::font_size(app))
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Clickable {
|
||||
items: clickable_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
} => {
|
||||
let message_items = render_message_bar_items(clickable_items, app);
|
||||
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |_| message_items)
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
message_items
|
||||
}
|
||||
}
|
||||
MessageItem::Chip {
|
||||
items: chip_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
..
|
||||
} => {
|
||||
let chip_items = chip_items.clone();
|
||||
let action = action.clone();
|
||||
if !disabled {
|
||||
Hoverable::new(mouse_state.clone(), move |state| {
|
||||
render_message_chip_container(
|
||||
render_message_bar_items(&chip_items, app),
|
||||
state.is_hovered(),
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
render_message_chip_container(
|
||||
render_message_bar_items(chip_items.as_slice(), app),
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => ConstrainedBox::new(Image::new(source.clone(), CacheOption::BySize).finish())
|
||||
.with_width(*width)
|
||||
.with_height(*height)
|
||||
.finish(),
|
||||
};
|
||||
if i < items.len() - 1 {
|
||||
let next_item = &items[i + 1];
|
||||
let should_space_inline = matches!(item, MessageItem::Text { content, .. } | MessageItem::Hyperlink { content, .. } if content.ends_with(' '))
|
||||
|| matches!(next_item, MessageItem::Text { content, .. } | MessageItem::Hyperlink { content, .. } if content.starts_with(' '));
|
||||
|
||||
if !should_space_inline {
|
||||
let spacing = match (item, next_item) {
|
||||
(
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
MessageItem::Text { .. } | MessageItem::Hyperlink { .. },
|
||||
)
|
||||
| (
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
) => Some(4.0),
|
||||
(
|
||||
MessageItem::Text { .. } | MessageItem::Hyperlink { .. },
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
)
|
||||
| (_, MessageItem::Clickable { .. } | MessageItem::Chip { .. })
|
||||
| (MessageItem::Clickable { .. } | MessageItem::Chip { .. }, _) => Some(8.0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(spacing) = spacing {
|
||||
child = Container::new(child).with_margin_right(spacing).finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row.add_child(child);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
pub fn render_terminal_message(message: Message, app: &AppContext) -> Box<dyn Element> {
|
||||
render_terminal_message_items(&message.items, app)
|
||||
}
|
||||
|
||||
/// Renders a sequence of message items into a flex row with terminal-specific styling.
|
||||
/// Currently used for terminal message bar items and use agent footer message items.
|
||||
fn render_terminal_message_items(items: &[MessageItem], app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let default_text_color = theme.disabled_text_color(theme.background()).into_solid();
|
||||
let font_family = appearance.monospace_font_family();
|
||||
let font_size = appearance.monospace_font_size() - 2.;
|
||||
let icon_size = font_size;
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_constrain_horizontal_bounds_to_parent(true);
|
||||
|
||||
for item in items {
|
||||
let child = match item {
|
||||
MessageItem::Keystroke {
|
||||
keystroke, color, ..
|
||||
} => {
|
||||
let keystroke_color = color.unwrap_or(default_text_color);
|
||||
let keys = keystroke_to_keys(keystroke);
|
||||
let mut key_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
let mut key_text = key.text(true);
|
||||
let rendered_key = if key_text == "⏎" {
|
||||
ConstrainedBox::new(
|
||||
Icon::CornerDownLeft
|
||||
.to_warpui_icon(Fill::Solid(keystroke_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
} else if key_text == "⇧" {
|
||||
ConstrainedBox::new(
|
||||
Icon::ArrowBlockUp
|
||||
.to_warpui_icon(Fill::Solid(keystroke_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
} else {
|
||||
if !cfg!(target_os = "macos") && i < (keys.len() - 1) {
|
||||
key_text = format!("{key_text}-").into();
|
||||
}
|
||||
Text::new(key_text, font_family, icon_size)
|
||||
.with_color(keystroke_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
};
|
||||
|
||||
if i == 0 {
|
||||
key_row.add_child(rendered_key);
|
||||
} else {
|
||||
key_row
|
||||
.add_child(Container::new(rendered_key).with_margin_left(2.).finish());
|
||||
}
|
||||
}
|
||||
|
||||
key_row.finish()
|
||||
}
|
||||
MessageItem::Text { content, color } => {
|
||||
let text_color = color.unwrap_or(default_text_color);
|
||||
Text::new(content.clone(), font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Hyperlink {
|
||||
content,
|
||||
url,
|
||||
color,
|
||||
mouse_state,
|
||||
} => {
|
||||
let link_color = color.unwrap_or_else(|| theme.accent().into());
|
||||
let content = content.clone();
|
||||
let url = url.clone();
|
||||
Hoverable::new(mouse_state.clone(), |_| {
|
||||
Text::new(content.clone(), font_family, font_size)
|
||||
.with_color(link_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |_, app, _| {
|
||||
app.open_url(&url);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Icon { icon, color } => {
|
||||
let icon_color = color.unwrap_or(default_text_color);
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Clickable {
|
||||
items: clickable_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
} => {
|
||||
let message_items = render_terminal_message_items(clickable_items, app);
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |_| message_items)
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
message_items
|
||||
}
|
||||
}
|
||||
MessageItem::Chip {
|
||||
items: chip_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
..
|
||||
} => {
|
||||
let chip_items = chip_items.clone();
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |state| {
|
||||
render_message_chip_container(
|
||||
render_terminal_message_items(&chip_items, app),
|
||||
state.is_hovered(),
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
render_message_chip_container(
|
||||
render_terminal_message_items(chip_items.as_slice(), app),
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => ConstrainedBox::new(Image::new(source.clone(), CacheOption::BySize).finish())
|
||||
.with_width(*width)
|
||||
.with_height(*height)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
row.add_child(child);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_message_chip_container(
|
||||
content: Box<dyn Element>,
|
||||
is_hovered: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let background = if is_hovered {
|
||||
blended_colors::fg_overlay_2(theme)
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(theme)
|
||||
};
|
||||
|
||||
Container::new(content)
|
||||
.with_background(background)
|
||||
.with_border(
|
||||
Border::all(styles::CHIP_BORDER_WIDTH)
|
||||
.with_border_color(blended_colors::neutral_3(theme)),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
styles::CHIP_CORNER_RADIUS,
|
||||
)))
|
||||
.with_vertical_padding(styles::CHIP_VERTICAL_PADDING)
|
||||
.with_horizontal_padding(styles::CHIP_HORIZONTAL_PADDING)
|
||||
.with_vertical_margin(-(styles::CHIP_VERTICAL_PADDING + styles::CHIP_BORDER_WIDTH))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns the background and foreground colors for a message item that can be disabled.
|
||||
pub fn disableable_message_item_color_overrides(
|
||||
is_disabled: bool,
|
||||
app: &AppContext,
|
||||
) -> (Option<ColorU>, Option<ColorU>) {
|
||||
if !is_disabled {
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
(
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(agent_view_bg_color(app).into())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_2(appearance.theme())),
|
||||
)
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn font_size(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.monospace_font_size() - 2.
|
||||
}
|
||||
|
||||
pub fn default_font_color(app: &AppContext) -> ColorU {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
theme
|
||||
.sub_text_color(blended_colors::neutral_1(theme).into())
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
pub const VERTICAL_PADDING: f32 = 8.;
|
||||
pub const CHIP_CORNER_RADIUS: f32 = 2.;
|
||||
pub const CHIP_BORDER_WIDTH: f32 = 1.;
|
||||
pub const CHIP_VERTICAL_PADDING: f32 = 2.;
|
||||
pub const CHIP_HORIZONTAL_PADDING: f32 = 4.;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Shared types for message bar rendering across terminal and agent views.
|
||||
pub mod attached_context;
|
||||
pub mod common;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::EventContext;
|
||||
/// A trait for types that can produce a message based on some contextual arguments.
|
||||
///
|
||||
/// The generic `Args` parameter allows each call site to define its own
|
||||
/// context-specific arguments (e.g., terminal-specific or agent-specific state).
|
||||
pub trait MessageProvider<Args> {
|
||||
/// Produces a message based on the given arguments, or `None` if this
|
||||
/// provider does not apply in the current context.
|
||||
fn produce_message(&self, args: Args) -> Option<Message>;
|
||||
}
|
||||
|
||||
/// A trait for types that can transform an existing message based on some contextual arguments.
|
||||
pub trait MessageTransformer<Args> {
|
||||
/// Modifies the given message.
|
||||
///
|
||||
/// Returns `true` if the message was indeed modified.
|
||||
fn transform_message(&self, message: &mut Message, args: Args) -> bool;
|
||||
}
|
||||
|
||||
/// A message to be displayed in a message bar, composed of a sequence of items.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Message {
|
||||
/// The items that make up this message.
|
||||
pub items: Vec<MessageItem>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Creates a new message with the given items.
|
||||
pub fn new(items: Vec<MessageItem>) -> Self {
|
||||
Self { items }
|
||||
}
|
||||
|
||||
/// Creates a message from a single text string.
|
||||
pub fn from_text(text: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
items: vec![MessageItem::text(text)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends text to the last item if it's a Text item, otherwise adds a new text item.
|
||||
pub fn append_text(&mut self, text: &str) {
|
||||
if let Some(MessageItem::Text { content, .. }) = self.items.last_mut() {
|
||||
*content = Cow::Owned(format!("{}{}", content, text));
|
||||
} else {
|
||||
self.items.push(MessageItem::text(text.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the color override for all text items in the message.
|
||||
pub fn with_text_color(mut self, color: ColorU) -> Self {
|
||||
for item in &mut self.items {
|
||||
item.set_text_color(color);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color override for all items in the message.
|
||||
pub fn with_color(mut self, color: ColorU) -> Self {
|
||||
self.set_color(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color override for all items in the message.
|
||||
pub fn set_color(&mut self, color: ColorU) {
|
||||
for item in &mut self.items {
|
||||
item.set_color(color);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_items(self) -> Vec<MessageItem> {
|
||||
self.items
|
||||
}
|
||||
}
|
||||
|
||||
pub type ClickHandler = Arc<dyn Fn(&mut EventContext) + 'static>;
|
||||
|
||||
/// Horizontal alignment for chip items in the message bar.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
|
||||
pub enum ChipHorizontalAlignment {
|
||||
/// Align the chip to the left (default behavior).
|
||||
#[default]
|
||||
Left,
|
||||
/// Align the chip to the right of the message bar.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// An item within a message, either a keystroke, text, icon, or interactive group.
|
||||
#[derive(Clone)]
|
||||
pub enum MessageItem {
|
||||
/// A keyboard shortcut/keystroke to display.
|
||||
Keystroke {
|
||||
/// The keystroke to display.
|
||||
keystroke: Keystroke,
|
||||
/// Optional color override for this keystroke.
|
||||
color: Option<ColorU>,
|
||||
/// Optional background color override for this keystroke,
|
||||
background_color: Option<ColorU>,
|
||||
},
|
||||
/// Text content with an optional color override.
|
||||
Text {
|
||||
/// The text content.
|
||||
content: Cow<'static, str>,
|
||||
/// Optional color override for this text.
|
||||
color: Option<ColorU>,
|
||||
},
|
||||
/// A hyperlink that opens a URL.
|
||||
Hyperlink {
|
||||
/// The link text.
|
||||
content: Cow<'static, str>,
|
||||
/// The target URL.
|
||||
url: String,
|
||||
/// Optional color override for this link.
|
||||
color: Option<ColorU>,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
},
|
||||
/// An icon with an optional color override.
|
||||
Icon {
|
||||
/// The icon to display.
|
||||
icon: Icon,
|
||||
/// Optional color override for this icon.
|
||||
color: Option<ColorU>,
|
||||
},
|
||||
/// A clickable group of items.
|
||||
Clickable {
|
||||
/// The items in this clickable group.
|
||||
items: Vec<MessageItem>,
|
||||
/// Click handler for the group.
|
||||
action: ClickHandler,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
/// Whether this clickable is disabled (no hover/click behavior).
|
||||
disabled: bool,
|
||||
},
|
||||
/// A clickable chip group of items with container styling handled by renderers.
|
||||
Chip {
|
||||
/// The items rendered inside this chip.
|
||||
items: Vec<MessageItem>,
|
||||
/// Click handler for the chip.
|
||||
action: ClickHandler,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
/// Whether this chip is disabled (no hover/click behavior).
|
||||
disabled: bool,
|
||||
/// Horizontal alignment of the chip within the message bar.
|
||||
horizontal_alignment: ChipHorizontalAlignment,
|
||||
},
|
||||
/// A raw image rendered from an asset source at a specific size.
|
||||
/// Unlike `Icon`, this preserves the original colors of the asset (e.g. colored SVGs).
|
||||
Image {
|
||||
/// The asset source for the image.
|
||||
source: AssetSource,
|
||||
/// Display width in logical pixels.
|
||||
width: f32,
|
||||
/// Display height in logical pixels.
|
||||
height: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MessageItem {
|
||||
/// Creates a keystroke item from a `Keystroke`.
|
||||
pub fn keystroke(keystroke: Keystroke) -> Self {
|
||||
Self::Keystroke {
|
||||
keystroke,
|
||||
color: None,
|
||||
background_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a text item with no color override.
|
||||
pub fn text(content: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self::Text {
|
||||
content: content.into(),
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a hyperlink item.
|
||||
pub fn hyperlink(
|
||||
content: impl Into<Cow<'static, str>>,
|
||||
url: String,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
Self::Hyperlink {
|
||||
content: content.into(),
|
||||
url,
|
||||
color: None,
|
||||
mouse_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an icon item with no color override.
|
||||
pub fn icon(icon: Icon) -> Self {
|
||||
Self::Icon { icon, color: None }
|
||||
}
|
||||
|
||||
/// Creates an image item from an asset source at the given size.
|
||||
pub fn image(source: AssetSource, width: f32, height: f32) -> Self {
|
||||
Self::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a clickable group of items.
|
||||
pub fn clickable(
|
||||
items: Vec<MessageItem>,
|
||||
action: impl Fn(&mut EventContext) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!items.iter().any(|i| matches!(i, Self::Clickable { .. })),
|
||||
"Nested clickable message items are not supported"
|
||||
);
|
||||
Self::Clickable {
|
||||
items,
|
||||
action: Arc::new(action),
|
||||
mouse_state,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a clickable chip group of items.
|
||||
pub fn chip(
|
||||
items: Vec<MessageItem>,
|
||||
action: impl Fn(&mut EventContext) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|i| matches!(i, Self::Clickable { .. } | Self::Chip { .. })),
|
||||
"Nested interactive message items are not supported"
|
||||
);
|
||||
Self::Chip {
|
||||
items,
|
||||
action: Arc::new(action),
|
||||
mouse_state,
|
||||
disabled: false,
|
||||
horizontal_alignment: ChipHorizontalAlignment::default(),
|
||||
}
|
||||
}
|
||||
/// Sets the color for text items.
|
||||
pub fn set_text_color(&mut self, color: ColorU) {
|
||||
match self {
|
||||
Self::Text {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Hyperlink {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Clickable { items, .. } | Self::Chip { items, .. } => {
|
||||
for item in items {
|
||||
item.set_text_color(color);
|
||||
}
|
||||
}
|
||||
Self::Keystroke { .. } | Self::Icon { .. } | Self::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the color for all items.
|
||||
pub fn set_color(&mut self, color: ColorU) {
|
||||
match self {
|
||||
Self::Text {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Hyperlink {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Keystroke {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Clickable { items, .. } | Self::Chip { items, .. } => {
|
||||
for item in items {
|
||||
item.set_color(color);
|
||||
}
|
||||
}
|
||||
Self::Icon {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets disabled state for clickable items.
|
||||
/// This only affects interactivity (hover/click behavior).
|
||||
/// For visual styling, use `set_color()` or `set_text_color()`.
|
||||
pub fn set_is_disabled(&mut self, is_disabled: bool) {
|
||||
match self {
|
||||
Self::Clickable {
|
||||
disabled, items, ..
|
||||
}
|
||||
| Self::Chip {
|
||||
disabled, items, ..
|
||||
} => {
|
||||
*disabled = is_disabled;
|
||||
for item in items {
|
||||
item.set_is_disabled(is_disabled);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets disabled state for clickable items and returns self.
|
||||
pub fn with_is_disabled(mut self, is_disabled: bool) -> Self {
|
||||
self.set_is_disabled(is_disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal alignment for chip items and returns self.
|
||||
/// Has no effect on non-chip items.
|
||||
pub fn with_horizontal_alignment(mut self, alignment: ChipHorizontalAlignment) -> Self {
|
||||
if let Self::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*horizontal_alignment = alignment;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmptyMessageProducer;
|
||||
|
||||
impl<T> MessageProvider<T> for EmptyMessageProducer {
|
||||
fn produce_message(&self, _: T) -> Option<Message> {
|
||||
Some(Message::from_text(""))
|
||||
}
|
||||
}
|
||||
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
|
||||
/// Returns a truncated command string for display in message bars.
|
||||
/// Limits to 27 characters (including ellipsis) if truncated.
|
||||
pub fn truncated_command_for_block(command: &str) -> String {
|
||||
truncate_from_end(command, 27)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
|
||||
use super::{ChipHorizontalAlignment, MessageItem};
|
||||
|
||||
#[test]
|
||||
fn chip_constructor_creates_enabled_chip_with_items() {
|
||||
let item = MessageItem::chip(
|
||||
vec![
|
||||
MessageItem::text("hello"),
|
||||
MessageItem::keystroke(Default::default()),
|
||||
],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
items, disabled, ..
|
||||
} => {
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(!disabled);
|
||||
}
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_constructor_creates_icon_without_color_override() {
|
||||
let item = MessageItem::icon(Icon::NewConversation);
|
||||
|
||||
match item {
|
||||
MessageItem::Icon { icon, color } => {
|
||||
assert!(matches!(icon, Icon::NewConversation));
|
||||
assert!(color.is_none());
|
||||
}
|
||||
_ => panic!("expected icon variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_color_sets_icon_color_override() {
|
||||
let color = pathfinder_color::ColorU {
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 255,
|
||||
};
|
||||
let mut item = MessageItem::icon(Icon::MessagePlusSquare);
|
||||
item.set_color(color);
|
||||
|
||||
match item {
|
||||
MessageItem::Icon {
|
||||
color: Some(item_color),
|
||||
..
|
||||
} => assert_eq!(item_color, color),
|
||||
_ => panic!("expected icon variant with color override"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_constructor_creates_image_with_correct_dimensions() {
|
||||
let item = MessageItem::image(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/test.svg",
|
||||
},
|
||||
16.,
|
||||
16.,
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
assert!(matches!(
|
||||
source,
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/test.svg"
|
||||
}
|
||||
));
|
||||
assert_eq!(width, 16.);
|
||||
assert_eq!(height, 16.);
|
||||
}
|
||||
_ => panic!("expected image variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_is_disabled_marks_chip_as_disabled() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)
|
||||
.with_is_disabled(true);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip { disabled, .. } => assert!(disabled),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chip_constructor_defaults_to_left_alignment() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} => assert_eq!(horizontal_alignment, ChipHorizontalAlignment::Left),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_horizontal_alignment_sets_right_alignment() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)
|
||||
.with_horizontal_alignment(ChipHorizontalAlignment::Right);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} => assert_eq!(horizontal_alignment, ChipHorizontalAlignment::Right),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_disabled_propagates_to_nested_interactive_items() {
|
||||
let mut item = MessageItem::Chip {
|
||||
items: vec![MessageItem::clickable(
|
||||
vec![MessageItem::text("nested")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)],
|
||||
action: std::sync::Arc::new(|_ctx| {}),
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
disabled: false,
|
||||
horizontal_alignment: ChipHorizontalAlignment::default(),
|
||||
};
|
||||
item.set_is_disabled(true);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
disabled, items, ..
|
||||
} => {
|
||||
assert!(disabled);
|
||||
match &items[0] {
|
||||
MessageItem::Clickable { disabled, .. } => assert!(*disabled),
|
||||
_ => panic!("expected nested clickable item"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, FormattedTextElement, Highlight, HighlightedHyperlink,
|
||||
MouseStateHandle, Radius, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, EntityId, SingletonEntity as _};
|
||||
|
||||
use crate::ai::llms::{
|
||||
is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo, LLMPreferences, LLMProvider,
|
||||
LLMSpec,
|
||||
};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::data_source::{Query, QueryFilter, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
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,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::{styles as inline_styles, DetailsRenderConfig};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::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,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptModel {
|
||||
pub id: LLMId,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptModel {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ModelSelector;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
if !FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
return Some(Message::new(default_navigation_message_items(&args)));
|
||||
}
|
||||
|
||||
let mut items = vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to select"),
|
||||
MessageItem::keystroke(if OperatingSystem::get().is_mac() {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
MessageItem::text(" select and save to profile"),
|
||||
];
|
||||
|
||||
if args.inline_menu_model.tab_configs().len() > 1 {
|
||||
items.push(MessageItem::keystroke(Keystroke {
|
||||
key: "tab".to_owned(),
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}));
|
||||
items.push(MessageItem::text(" to cycle tabs"));
|
||||
}
|
||||
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to dismiss"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
crate::terminal::input::inline_menu::InlineMenuRowAction::<Self>::Dismiss,
|
||||
);
|
||||
},
|
||||
args.inline_menu_model.mouse_states().dismiss.clone(),
|
||||
));
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
|
||||
fn details_render_config(app: &AppContext) -> Option<DetailsRenderConfig> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let max_item_width = app.font_cache().em_width(
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
) * 40.;
|
||||
Some(DetailsRenderConfig {
|
||||
min_required_details_width: Some(model_specs_width(app)),
|
||||
max_result_width: Some(max_item_width),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn model_specs_width(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
app.font_cache().em_width(
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
) * 34.
|
||||
}
|
||||
|
||||
pub struct ModelSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
impl ModelSelectorDataSource {
|
||||
pub fn new(terminal_view_id: EntityId) -> Self {
|
||||
Self { terminal_view_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ModelSelectorDataSource {
|
||||
type Action = AcceptModel;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let llm_preferences = LLMPreferences::as_ref(app);
|
||||
let is_full_terminal = query.filters.contains(&QueryFilter::FullTerminalUseModels);
|
||||
|
||||
let active_llm_id = if is_full_terminal {
|
||||
llm_preferences
|
||||
.get_active_cli_agent_model(app, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone()
|
||||
} else {
|
||||
llm_preferences
|
||||
.get_active_base_model(app, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone()
|
||||
};
|
||||
|
||||
let choices: Vec<&LLMInfo> = if is_full_terminal {
|
||||
llm_preferences.get_cli_agent_llm_choices().collect_vec()
|
||||
} else {
|
||||
llm_preferences
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.collect_vec()
|
||||
};
|
||||
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
if query_text.is_empty() {
|
||||
return Ok(choices
|
||||
.into_iter()
|
||||
.map(|llm| QueryResult::from(ModelSearchItem::new(llm, &active_llm_id, app)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(choices
|
||||
.into_iter()
|
||||
.filter_map(|llm| {
|
||||
let match_result = match_indices_case_insensitive(
|
||||
llm.display_name.to_lowercase().as_str(),
|
||||
query_text.as_str(),
|
||||
)?;
|
||||
|
||||
// Avoid spamming results with extremely weak matches.
|
||||
if query_text.len() > 1 && match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
ModelSearchItem::new(llm, &active_llm_id, app)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ModelSelectorDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ModelSearchItem {
|
||||
id: LLMId,
|
||||
provider: LLMProvider,
|
||||
spec: Option<LLMSpec>,
|
||||
provider_icon: Option<Icon>,
|
||||
display_text: String,
|
||||
is_selected: bool,
|
||||
disable_reason: Option<DisableReason>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
manage_api_key_mouse_state: MouseStateHandle,
|
||||
reasoning_level: Option<String>,
|
||||
discount_percentage: Option<f32>,
|
||||
}
|
||||
|
||||
impl ModelSearchItem {
|
||||
fn new(llm: &LLMInfo, active_llm_id: &LLMId, app: &AppContext) -> Self {
|
||||
// If the model requires an upgrade but the user already has a BYOK key
|
||||
// for this provider, treat it as enabled by clearing the disable reason.
|
||||
let disable_reason = if llm.disable_reason == Some(DisableReason::RequiresUpgrade)
|
||||
&& is_using_api_key_for_provider(&llm.provider, app)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
llm.disable_reason.clone()
|
||||
};
|
||||
Self {
|
||||
id: llm.id.clone(),
|
||||
provider: llm.provider.clone(),
|
||||
spec: llm.spec.clone(),
|
||||
provider_icon: llm.provider.icon(),
|
||||
display_text: llm.display_name.clone(),
|
||||
is_selected: &llm.id == active_llm_id,
|
||||
disable_reason,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
manage_api_key_mouse_state: Default::default(),
|
||||
reasoning_level: llm.reasoning_level(),
|
||||
discount_percentage: llm.discount_percentage,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ModelSearchItem {
|
||||
type Action = AcceptModel;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
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_warpui_icon(icon_color)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use warpui::elements::{Flex, ParentElement as _};
|
||||
use warpui::prelude::CrossAxisAlignment;
|
||||
|
||||
let appearance = crate::appearance::Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color =
|
||||
inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
let name_text_color = if self.is_disabled() {
|
||||
secondary_text_color
|
||||
} else {
|
||||
primary_text_color
|
||||
};
|
||||
|
||||
let mut text = Text::new_inline(
|
||||
self.display_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(name_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
text = text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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_warpui_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());
|
||||
}
|
||||
|
||||
if self.is_selected {
|
||||
let selected_label = "(selected)";
|
||||
let selected_text = Text::new_inline(
|
||||
selected_label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
}),
|
||||
(0..selected_label.len()).collect(),
|
||||
)
|
||||
.finish();
|
||||
row = row.with_child(Container::new(selected_text).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
if self.is_disabled() {
|
||||
let disabled_label = "(disabled)";
|
||||
let disabled_text = Text::new_inline(
|
||||
disabled_label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
}),
|
||||
(0..disabled_label.len()).collect(),
|
||||
)
|
||||
.finish();
|
||||
row = row.with_child(Container::new(disabled_text).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
if should_show_discount_chip(
|
||||
self.discount_percentage,
|
||||
is_using_api_key_for_provider(&self.provider, app),
|
||||
) {
|
||||
let discount_percentage = self.discount_percentage.unwrap_or(0.);
|
||||
let chip = Container::new(
|
||||
Text::new_inline(
|
||||
format!("{}% off!", discount_percentage.round() as u32),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(theme.ansi_fg_green())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(4.)
|
||||
.with_padding_right(4.)
|
||||
.with_background(theme.green_overlay_1())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_left(6.)
|
||||
.finish();
|
||||
row = row.with_child(chip);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn render_details(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
use warpui::elements::{Flex, ParentElement as _};
|
||||
|
||||
let appearance = crate::appearance::Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let (title, description) = if self.reasoning_level.is_some() {
|
||||
(REASONING_LEVEL_TITLE, REASONING_LEVEL_DESCRIPTION)
|
||||
} else {
|
||||
(MODEL_SPECS_TITLE, MODEL_SPECS_DESCRIPTION)
|
||||
};
|
||||
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 manage_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.manage_api_key_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Manage".to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
height: Some(24.),
|
||||
padding: Some(Coords {
|
||||
top: 2.,
|
||||
bottom: 2.,
|
||||
left: 4.,
|
||||
right: 4.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_cursor(Some(Cursor::PointingHand))
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
CostRow::BilledToApi {
|
||||
manage_button: Container::new(manage_button).finish(),
|
||||
}
|
||||
} else {
|
||||
CostRow::Bar {
|
||||
value: self.spec.as_ref().map(|spec| spec.cost),
|
||||
}
|
||||
};
|
||||
|
||||
let scores = render_model_spec_scores(
|
||||
self.spec.as_ref(),
|
||||
cost_row,
|
||||
ModelSpecScoresLayout {
|
||||
bg_bar_color: internal_colors::neutral_3(theme),
|
||||
},
|
||||
app,
|
||||
);
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_child(Container::new(header).with_margin_bottom(12.).finish())
|
||||
.with_child(scores);
|
||||
|
||||
if self.disable_reason.as_ref() == Some(&DisableReason::RequiresUpgrade) {
|
||||
let upgrade_url = if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.user_id()
|
||||
.unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
};
|
||||
|
||||
let mut display_name = self.display_text.clone();
|
||||
if let Some(first) = display_name.get_mut(..1) {
|
||||
first.make_ascii_uppercase();
|
||||
}
|
||||
|
||||
// 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()
|
||||
&& matches!(
|
||||
self.provider,
|
||||
LLMProvider::OpenAI | LLMProvider::Anthropic | LLMProvider::Google
|
||||
);
|
||||
|
||||
let mut text_fragments = vec![
|
||||
FormattedTextFragment::plain_text(format!(
|
||||
"{display_name} is not available for free users. "
|
||||
)),
|
||||
FormattedTextFragment::hyperlink("Upgrade", upgrade_url),
|
||||
];
|
||||
|
||||
if byok_available {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" or ".to_string()));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
"bring your own key",
|
||||
WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let upgrade_text = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(text_fragments)]),
|
||||
inline_styles::font_size(appearance),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
theme.disabled_ui_text_color().into_solid(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(|hyperlink_lens, event, ctx| {
|
||||
match hyperlink_lens {
|
||||
warpui::elements::HyperlinkLens::Url(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
warpui::elements::HyperlinkLens::Action(action_ref) => {
|
||||
if let Some(action) = action_ref.as_any().downcast_ref::<WorkspaceAction>()
|
||||
{
|
||||
event.dispatch_typed_action(action.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
column = column.with_child(Container::new(upgrade_text).with_margin_top(12.).finish());
|
||||
}
|
||||
|
||||
Some(
|
||||
ConstrainedBox::new(column.finish())
|
||||
.with_width(model_specs_width(app))
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn priority_tier(&self) -> u8 {
|
||||
if self.is_disabled() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptModel {
|
||||
id: self.id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn is_disabled(&self) -> bool {
|
||||
self.disable_reason.is_some()
|
||||
}
|
||||
|
||||
fn tooltip(&self) -> Option<String> {
|
||||
self.disable_reason
|
||||
.as_ref()
|
||||
.map(|reason| reason.tooltip_text().to_string())
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
let mut label = format!("Model: {}", self.display_text);
|
||||
if self.is_selected {
|
||||
label.push_str(" (selected)");
|
||||
}
|
||||
if self.is_disabled() {
|
||||
label.push_str(" (disabled)");
|
||||
}
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a promo discount chip should be shown for a model.
|
||||
/// Discounts only apply when the user is billing through Warp credits,
|
||||
/// so we suppress the chip when the user is routing through their own API key.
|
||||
fn should_show_discount_chip(discount_percentage: Option<f32>, is_using_byok: bool) -> bool {
|
||||
discount_percentage.is_some_and(|p| p > 0.) && !is_using_byok
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod data_source;
|
||||
mod model_spec_scores;
|
||||
mod view;
|
||||
|
||||
pub use data_source::{AcceptModel, ModelSelectorDataSource};
|
||||
pub use view::{InlineModelSelectorEvent, InlineModelSelectorTab, InlineModelSelectorView};
|
||||
@@ -0,0 +1,257 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, Expanded, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement as _, Percentage, Radius, Rect, Stack, Text,
|
||||
};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity as _};
|
||||
|
||||
use crate::ai::llms::LLMSpec;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
|
||||
const CORNER_RADIUS: f32 = 4.0;
|
||||
const ROW_SPACING: f32 = 12.0;
|
||||
|
||||
pub const MODEL_SPECS_TITLE: &str = "Model Specs";
|
||||
pub const MODEL_SPECS_DESCRIPTION: &str = "Warp's benchmarks for how well a model performs in our harness, the rate at which it consumes credits, and task speed.";
|
||||
|
||||
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 enum CostRow {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
}
|
||||
|
||||
pub struct ModelSpecScoresLayout {
|
||||
pub bg_bar_color: ColorU,
|
||||
}
|
||||
|
||||
pub fn render_model_spec_scores(
|
||||
spec: Option<&LLMSpec>,
|
||||
cost_row: CostRow,
|
||||
layout: ModelSpecScoresLayout,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut rows = vec![render_score_row(
|
||||
"Intelligence",
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.quality),
|
||||
},
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
)];
|
||||
|
||||
rows.push(render_score_row(
|
||||
"Speed",
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.speed),
|
||||
},
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
|
||||
match cost_row {
|
||||
CostRow::Bar { value } => {
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::Bar { value },
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
}
|
||||
CostRow::BilledToApi { manage_button } => {
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::BilledToApi { manage_button },
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(ROW_SPACING)
|
||||
.with_children(rows)
|
||||
.finish()
|
||||
}
|
||||
|
||||
enum ScoreRowKind {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
}
|
||||
|
||||
fn render_score_row(
|
||||
name: &str,
|
||||
kind: ScoreRowKind,
|
||||
bg_bar_color: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Approximate the required width for the longest label ("Intelligence"), and use this as a
|
||||
// consistent width for the labels so the labels and bars are vertically aligned.
|
||||
//
|
||||
// 8 ems is enough space for Intelligence with some right margin to spare.
|
||||
let label_width = app.font_cache().em_width(
|
||||
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 bar_height = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
);
|
||||
|
||||
let row_content: Box<dyn Element> = match kind {
|
||||
ScoreRowKind::Bar { value: Some(value) } => {
|
||||
let background_bar = Rect::new()
|
||||
.with_background_color(bg_bar_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
let filled_bar = Rect::new()
|
||||
.with_background_color(internal_colors::neutral_6(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Expanded::new(
|
||||
1.,
|
||||
ConstrainedBox::new(
|
||||
Stack::new()
|
||||
.with_child(background_bar)
|
||||
.with_child(Percentage::width(value, filled_bar).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(bar_height)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ScoreRowKind::Bar { value: None } => {
|
||||
let background_bar = Rect::new()
|
||||
.with_background_color(bg_bar_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Expanded::new(
|
||||
1.,
|
||||
ConstrainedBox::new(
|
||||
Stack::new()
|
||||
.with_child(background_bar)
|
||||
.with_child(
|
||||
Align::new(
|
||||
Text::new(
|
||||
"?",
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(bg_bar_color.into())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(bar_height)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ScoreRowKind::BilledToApi { 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(manage_button)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(Expanded::new(1., row_content).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_model_spec_header(
|
||||
title: &str,
|
||||
description: &str,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let title = Text::new(
|
||||
title.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(),
|
||||
)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.finish();
|
||||
|
||||
let description = Text::new(
|
||||
description.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(theme.disabled_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(4.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(12.)
|
||||
.with_border(Border::bottom(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{ChildView, MainAxisSize};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::llms::{LLMId, LLMPreferences, LLMPreferencesEvent};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuHeaderConfig, InlineMenuModel, InlineMenuPositioner,
|
||||
InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::models::data_source::{AcceptModel, ModelSelectorDataSource};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
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;
|
||||
|
||||
impl ActionButtonTheme for ManageDefaultsTheme {
|
||||
fn background(&self, _hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
let theme = appearance.theme();
|
||||
if hovered {
|
||||
internal_colors::text_main(theme, theme.background())
|
||||
} else {
|
||||
internal_colors::text_sub(theme, theme.background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InlineModelSelectorTab {
|
||||
BaseAgent,
|
||||
FullTerminalUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineModelSelectorEvent {
|
||||
SelectedModel {
|
||||
id: LLMId,
|
||||
selected_tab: InlineModelSelectorTab,
|
||||
set_as_default: bool,
|
||||
},
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
static TAB_CONFIGS: LazyLock<Vec<InlineMenuTabConfig<InlineModelSelectorTab>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut configs = vec![InlineMenuTabConfig {
|
||||
id: InlineModelSelectorTab::BaseAgent,
|
||||
label: "Base".to_string(),
|
||||
filters: HashSet::from([QueryFilter::BaseModels]),
|
||||
}];
|
||||
if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
configs.push(InlineMenuTabConfig {
|
||||
id: InlineModelSelectorTab::FullTerminalUse,
|
||||
label: "Full Terminal Use".to_string(),
|
||||
filters: HashSet::from([QueryFilter::FullTerminalUseModels]),
|
||||
});
|
||||
}
|
||||
configs
|
||||
});
|
||||
|
||||
struct TabSwitchSelection {
|
||||
model_id: Option<LLMId>,
|
||||
index: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct InlineModelSelectorView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptModel, InlineModelSelectorTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptModel>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
terminal_view_id: EntityId,
|
||||
/// Stashed selection state to restore after a tab switch requery.
|
||||
selection_before_tab_switch: Option<TabSwitchSelection>,
|
||||
/// Controls whether or not we should filter the contents of the menu
|
||||
/// based on the contents of the input.
|
||||
filter_results_by_input: bool,
|
||||
}
|
||||
|
||||
impl InlineModelSelectorView {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
cli_subagent_controller: ModelHandle<CLISubagentController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| ModelSelectorDataSource::new(terminal_view_id));
|
||||
|
||||
let tab_configs = TAB_CONFIGS.clone();
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptModel>::new();
|
||||
mixer.add_sync_source(
|
||||
data_source.clone(),
|
||||
[QueryFilter::BaseModels, QueryFilter::FullTerminalUseModels],
|
||||
);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
let manage_defaults_button = ctx.add_view(|_| {
|
||||
ActionButton::new("Manage defaults", ManageDefaultsTheme)
|
||||
.with_icon(Icon::Settings)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: String::new(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
});
|
||||
let header_config = InlineMenuHeaderConfig {
|
||||
label: "/model".to_string(),
|
||||
trailing_element: Some(Box::new(move |_app: &AppContext| {
|
||||
ChildView::new(&manage_defaults_button).finish()
|
||||
})),
|
||||
};
|
||||
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
let menu = InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.with_header_config(header_config);
|
||||
|
||||
let menu_model = menu.model().clone();
|
||||
let cli_ctrl = cli_subagent_controller.clone();
|
||||
menu.with_banner_fn(move |app| {
|
||||
let active_tab = menu_model
|
||||
.as_ref(app)
|
||||
.active_tab_id()
|
||||
.unwrap_or(InlineModelSelectorTab::BaseAgent);
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
|
||||
let main_agent_in_progress = history
|
||||
.active_conversation(terminal_view_id)
|
||||
.is_some_and(|c| !c.is_empty() && c.status().is_in_progress());
|
||||
let is_cli_agent_in_control_or_tagged_in =
|
||||
cli_ctrl.as_ref(app).is_agent_in_control_or_tagged_in();
|
||||
let message = match active_tab {
|
||||
InlineModelSelectorTab::FullTerminalUse if main_agent_in_progress && !is_cli_agent_in_control_or_tagged_in => {
|
||||
Some("You're using the base agent. Full terminal use models only apply to the full terminal use agent.")
|
||||
}
|
||||
InlineModelSelectorTab::BaseAgent if is_cli_agent_in_control_or_tagged_in => {
|
||||
Some("You're using the full terminal use agent. Base models only apply to the base agent.")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
message.map(|msg| {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Alert::new().render(
|
||||
AlertConfig::warning(msg.to_string())
|
||||
.with_main_axis_size(MainAxisSize::Max),
|
||||
appearance,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item,
|
||||
cmd_or_ctrl_shift_enter,
|
||||
} => {
|
||||
ctx.emit(InlineModelSelectorEvent::SelectedModel {
|
||||
id: item.id.clone(),
|
||||
selected_tab: me.active_tab(ctx),
|
||||
set_as_default: *cmd_or_ctrl_shift_enter,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineModelSelectorEvent::Dismissed);
|
||||
}
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.selection_before_tab_switch = Some(TabSwitchSelection {
|
||||
model_id: me
|
||||
.menu_model(ctx)
|
||||
.selected_item()
|
||||
.map(|item| item.id.clone()),
|
||||
index: me.menu_view.as_ref(ctx).selected_idx(),
|
||||
});
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
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.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, _, ctx| {
|
||||
if !me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !me.filter_results_by_input {
|
||||
// If the user clears the buffer, we should re-enable filtering so that
|
||||
// any fresh typing acts as a search query.
|
||||
if me.input_buffer_model.as_ref(ctx).current_value().is_empty() {
|
||||
me.filter_results_by_input = true;
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
me.rerun_query(ctx);
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&LLMPreferences::handle(ctx),
|
||||
|me, _, event, ctx| match event {
|
||||
LLMPreferencesEvent::UpdatedAvailableLLMs
|
||||
| LLMPreferencesEvent::UpdatedActiveAgentModeLLM
|
||||
if me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector() =>
|
||||
{
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&ApiKeyManager::handle(ctx), |me, _, event, ctx| {
|
||||
if !matches!(event, ApiKeyManagerEvent::KeysUpdated) {
|
||||
return;
|
||||
}
|
||||
if me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&cli_subagent_controller, |me, _, event, ctx| match event {
|
||||
CLISubagentEvent::SpawnedSubagent { .. }
|
||||
| CLISubagentEvent::FinishedSubagent { .. }
|
||||
| CLISubagentEvent::UpdatedControl { .. } => {
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
CLISubagentEvent::UpdatedLastSnapshot
|
||||
| CLISubagentEvent::ToggledHideResponses
|
||||
| CLISubagentEvent::ControlHandedBackAfterTransfer => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
move |me, _, event, ctx| {
|
||||
if let BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
terminal_view_id: event_terminal_view_id,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if *event_terminal_view_id == terminal_view_id {
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&mixer, |me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
|
||||
if !me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// On tab switch, try to preserve the previously selected model.
|
||||
// If the model isn't present in the new tab's results, fall back
|
||||
// to the same index position.
|
||||
if let Some(selection) = me.selection_before_tab_switch.take() {
|
||||
let found_by_id = selection.model_id.is_some_and(|id| {
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_first_where(|item| item.id == id, ctx)
|
||||
})
|
||||
});
|
||||
if !found_by_id {
|
||||
if let Some(idx) = selection.index {
|
||||
let count = me.menu_view.as_ref(ctx).result_count();
|
||||
if count > 0 {
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_idx(idx.min(count - 1), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user is actively filtering, don't override their selection.
|
||||
if me.filter_results_by_input
|
||||
&& !me.input_buffer_model.as_ref(ctx).current_value().is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let active_id = me.active_model_id_for_current_tab(ctx);
|
||||
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_first_where(|item| item.id == active_id, ctx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
terminal_view_id,
|
||||
selection_before_tab_switch: None,
|
||||
filter_results_by_input: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_model<'a>(
|
||||
&self,
|
||||
ctx: &'a ViewContext<Self>,
|
||||
) -> &'a InlineMenuModel<AcceptModel, InlineModelSelectorTab> {
|
||||
self.menu_view.as_ref(ctx).model().as_ref(ctx)
|
||||
}
|
||||
|
||||
fn active_tab(&self, ctx: &ViewContext<Self>) -> InlineModelSelectorTab {
|
||||
self.menu_model(ctx)
|
||||
.active_tab_id()
|
||||
.unwrap_or(InlineModelSelectorTab::BaseAgent)
|
||||
}
|
||||
|
||||
fn active_model_id_for_current_tab(&self, ctx: &ViewContext<Self>) -> LLMId {
|
||||
let llm_preferences = LLMPreferences::as_ref(ctx);
|
||||
match self.active_tab(ctx) {
|
||||
InlineModelSelectorTab::BaseAgent => llm_preferences
|
||||
.get_active_base_model(ctx, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone(),
|
||||
InlineModelSelectorTab::FullTerminalUse => llm_preferences
|
||||
.get_active_cli_agent_model(ctx, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self.menu_model(ctx).active_tab_filters();
|
||||
let text = if self.filter_results_by_input {
|
||||
self.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(Query { text, filters }, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn filter_results_by_input(&self) -> bool {
|
||||
self.filter_results_by_input
|
||||
}
|
||||
|
||||
pub fn set_filter_results_by_input(&mut self, filter: bool) {
|
||||
self.filter_results_by_input = filter;
|
||||
}
|
||||
|
||||
pub fn set_active_tab(&self, tab: InlineModelSelectorTab, ctx: &mut ViewContext<Self>) {
|
||||
let index = self
|
||||
.menu_view
|
||||
.as_ref(ctx)
|
||||
.model()
|
||||
.as_ref(ctx)
|
||||
.tab_configs()
|
||||
.iter()
|
||||
.position(|config| config.id == tab);
|
||||
if let Some(index) = index {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.set_active_tab(index, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(cmd_or_ctrl_enter, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineModelSelectorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineModelSelectorView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineModelSelectorView {
|
||||
type Event = InlineModelSelectorEvent;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Data source for the inline plan menu.
|
||||
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::plans::search_item::PlanSearchItem;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
|
||||
pub struct PlanMenuDataSource {
|
||||
conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
impl PlanMenuDataSource {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self { conversation_id }
|
||||
}
|
||||
|
||||
pub fn set_conversation_id(&mut self, id: AIConversationId) {
|
||||
self.conversation_id = id;
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for PlanMenuDataSource {
|
||||
type Action = AcceptPlan;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let doc_model = AIDocumentModel::as_ref(app);
|
||||
let docs = doc_model.get_all_documents_for_conversation(self.conversation_id);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
if query_text.is_empty() {
|
||||
// Documents are already sorted oldest-first (ascending created_at).
|
||||
// InlineMenuView renders items bottom-to-top (last item = bottom = most recent),
|
||||
// so this ordering places the most recent plan at the bottom as desired.
|
||||
return Ok(docs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (id, doc))| QueryResult::from(PlanSearchItem::new(id, doc, idx)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(docs
|
||||
.into_iter()
|
||||
.filter_map(|(id, doc)| {
|
||||
let match_result = match_indices_case_insensitive(&doc.title, &query_text)?;
|
||||
// Avoid spamming results with extremely weak matches. 10 is arbitrary
|
||||
if match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
let score = OrderedFloat(match_result.score as f64);
|
||||
Some(QueryResult::from(
|
||||
PlanSearchItem::new(id, doc, 0)
|
||||
.with_name_match_result(Some(match_result))
|
||||
.with_score(score),
|
||||
))
|
||||
})
|
||||
.sorted_by(|a, b| b.score().cmp(&a.score()))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PlanMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Inline plan menu for selecting among multiple AI document plans.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
/// Action emitted when a plan is selected from the inline plan menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptPlan {
|
||||
pub document_id: AIDocumentId,
|
||||
pub document_version: AIDocumentVersion,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptPlan {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::PlanMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let InlineMenuMessageArgs {
|
||||
inline_menu_model, ..
|
||||
} = args;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(item) = inline_menu_model.selected_item() {
|
||||
let accept_item = item.clone();
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" open plan"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: accept_item.clone(),
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! SearchItem implementation for plan menu items.
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::document::ai_document_model::{AIDocument, AIDocumentVersion};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
const ICON_SIZE: f32 = 14.;
|
||||
|
||||
/// Search item for rendering a plan in the inline plan menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct PlanSearchItem {
|
||||
document_id: AIDocumentId,
|
||||
title: String,
|
||||
version: AIDocumentVersion,
|
||||
created_at: chrono::DateTime<chrono::Local>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
/// Used as a stable ordering score (index in the list, or fuzzy match score).
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl PlanSearchItem {
|
||||
pub fn new(document_id: AIDocumentId, doc: AIDocument, index: usize) -> Self {
|
||||
Self {
|
||||
document_id,
|
||||
title: doc.title,
|
||||
version: doc.version,
|
||||
created_at: doc.created_at,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(index as f64),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for PlanSearchItem {
|
||||
type Action = AcceptPlan;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(Icon::Compass.to_warpui_icon(icon_color).finish())
|
||||
.with_width(ICON_SIZE)
|
||||
.with_height(ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
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 mut name_text =
|
||||
Text::new_inline(self.title.clone(), appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., name_text.finish()).finish());
|
||||
|
||||
let timestamp_font_size = font_size * 12. / 14.;
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(self.created_at.to_utc()),
|
||||
appearance.ui_font_family(),
|
||||
timestamp_font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), timestamp_font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptPlan {
|
||||
document_id: self.document_id,
|
||||
document_version: self.version,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Plan: {}", self.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Inline plan menu view for selecting among multiple AI document plans.
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::plans::data_source::PlanMenuDataSource;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
/// Events emitted by InlinePlanMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlinePlanMenuEvent {
|
||||
/// User selected a plan to open.
|
||||
OpenPlan {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
/// User dismissed the menu.
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct InlinePlanMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptPlan>>,
|
||||
data_source: ModelHandle<PlanMenuDataSource>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptPlan>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
}
|
||||
|
||||
impl InlinePlanMenuView {
|
||||
pub fn new(
|
||||
conversation_id: AIConversationId,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| PlanMenuDataSource::new(conversation_id));
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptPlan>::new();
|
||||
mixer.add_sync_source(data_source.clone(), []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => {
|
||||
ctx.emit(InlinePlanMenuEvent::OpenPlan {
|
||||
document_id: item.document_id,
|
||||
document_version: item.document_version,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. }
|
||||
| InlineMenuEvent::NoResults
|
||||
| InlineMenuEvent::TabChanged => (),
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlinePlanMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if let Some(conversation_id) = input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.plan_menu_conversation_id()
|
||||
{
|
||||
me.data_source.update(ctx, |ds, _| {
|
||||
ds.set_conversation_id(conversation_id);
|
||||
});
|
||||
me.refresh_results("", ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_plan_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.refresh_results(new_content, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
data_source,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&self, search_query: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: search_query.to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlinePlanMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlinePlanMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlinePlanMenuView {
|
||||
type Event = InlinePlanMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, EntityId, SingletonEntity};
|
||||
|
||||
use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId};
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuAction, InlineMenuType};
|
||||
use crate::terminal::input::profiles::search_item::ProfileSearchItem;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SelectProfileMenuItem {
|
||||
Profile { profile_id: ClientProfileId },
|
||||
ManageProfiles,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for SelectProfileMenuItem {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ProfileSelector;
|
||||
}
|
||||
|
||||
pub struct ProfileSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
impl ProfileSelectorDataSource {
|
||||
pub fn new(terminal_view_id: EntityId) -> Self {
|
||||
Self { terminal_view_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ProfileSelectorDataSource {
|
||||
type Action = SelectProfileMenuItem;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let profiles_model = AIExecutionProfilesModel::as_ref(app);
|
||||
let active_profile_id = *profiles_model
|
||||
.active_profile(Some(self.terminal_view_id), app)
|
||||
.id();
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
if query_text.is_empty() {
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_manage_profiles_item(),
|
||||
));
|
||||
} else if let Some(match_result) =
|
||||
match_indices_case_insensitive("manage profiles", &query_text)
|
||||
{
|
||||
let score = match_result.score;
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_manage_profiles_item()
|
||||
.with_match_result(match_result)
|
||||
.with_score(OrderedFloat(score as f64)),
|
||||
));
|
||||
}
|
||||
|
||||
let mut profiles: Vec<(ClientProfileId, String)> = profiles_model
|
||||
.get_all_profile_ids()
|
||||
.into_iter()
|
||||
.filter_map(|profile_id| {
|
||||
let profile_info = profiles_model.get_profile_by_id(profile_id, app)?;
|
||||
let profile_name = profile_info.data().display_name();
|
||||
Some((profile_id, profile_name))
|
||||
})
|
||||
.collect();
|
||||
profiles.sort_by(|(_, a), (_, b)| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
|
||||
for (profile_id, profile_name) in profiles {
|
||||
if query_text.is_empty() {
|
||||
results.push(QueryResult::from(ProfileSearchItem::new_profile_item(
|
||||
profile_id,
|
||||
profile_name,
|
||||
profile_id == active_profile_id,
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(match_result) =
|
||||
match_indices_case_insensitive(&profile_name.to_lowercase(), &query_text)
|
||||
{
|
||||
let score = match_result.score;
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_profile_item(
|
||||
profile_id,
|
||||
profile_name,
|
||||
profile_id == active_profile_id,
|
||||
)
|
||||
.with_match_result(match_result)
|
||||
.with_score(OrderedFloat(score as f64)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ProfileSelectorDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::SelectProfileMenuItem;
|
||||
pub use view::{InlineProfileSelectorEvent, InlineProfileSelectorView};
|
||||
@@ -0,0 +1,189 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Flex, Highlight, ParentElement as _, Text};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::prelude::CrossAxisAlignment;
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity as _};
|
||||
|
||||
use crate::ai::execution_profiles::profiles::ClientProfileId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::profiles::data_source::SelectProfileMenuItem;
|
||||
|
||||
const MANAGE_PROFILES_LABEL: &str = "Manage profiles";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ProfileSearchItemKind {
|
||||
Profile {
|
||||
profile_id: ClientProfileId,
|
||||
profile_name: String,
|
||||
is_selected: bool,
|
||||
},
|
||||
ManageProfiles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ProfileSearchItem {
|
||||
kind: ProfileSearchItemKind,
|
||||
match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl ProfileSearchItem {
|
||||
pub fn new_profile_item(
|
||||
profile_id: ClientProfileId,
|
||||
profile_name: String,
|
||||
is_selected: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: ProfileSearchItemKind::Profile {
|
||||
profile_id,
|
||||
profile_name,
|
||||
is_selected,
|
||||
},
|
||||
match_result: None,
|
||||
score: OrderedFloat(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_manage_profiles_item() -> Self {
|
||||
Self {
|
||||
kind: ProfileSearchItemKind::ManageProfiles,
|
||||
match_result: None,
|
||||
score: OrderedFloat(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_match_result(mut self, match_result: FuzzyMatchResult) -> Self {
|
||||
self.match_result = Some(match_result);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ProfileSearchItem {
|
||||
type Action = SelectProfileMenuItem;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon = match self.kind {
|
||||
ProfileSearchItemKind::Profile { .. } => Icon::Psychology,
|
||||
ProfileSearchItemKind::ManageProfiles => Icon::Gear,
|
||||
}
|
||||
.to_warpui_icon(inline_styles::icon_color(appearance));
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_width(inline_styles::font_size(appearance))
|
||||
.with_height(inline_styles::font_size(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let background = inline_styles::menu_background_color(app);
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
|
||||
let (label_text, is_selected) = match &self.kind {
|
||||
ProfileSearchItemKind::Profile {
|
||||
profile_name,
|
||||
is_selected,
|
||||
..
|
||||
} => (profile_name.clone(), *is_selected),
|
||||
ProfileSearchItemKind::ManageProfiles => (MANAGE_PROFILES_LABEL.to_owned(), false),
|
||||
};
|
||||
|
||||
let mut label = Text::new_inline(label_text, appearance.ui_font_family(), font_size)
|
||||
.with_color(
|
||||
inline_styles::primary_text_color(appearance.theme(), background.into()).into(),
|
||||
)
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
// Apply search highlighting to the label.
|
||||
if let Some(match_result) = &self.match_result {
|
||||
if !match_result.matched_indices.is_empty() {
|
||||
label = label.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label.finish());
|
||||
|
||||
if is_selected {
|
||||
let selected_label = "(selected)";
|
||||
let selected_text = Text::new_inline(
|
||||
selected_label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(
|
||||
inline_styles::secondary_text_color(appearance.theme(), background.into()).into(),
|
||||
)
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().style(Style::Italic)),
|
||||
(0..selected_label.len()).collect(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
row = row.with_child(Container::new(selected_text).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
match self.kind {
|
||||
ProfileSearchItemKind::Profile { profile_id, .. } => {
|
||||
SelectProfileMenuItem::Profile { profile_id }
|
||||
}
|
||||
ProfileSearchItemKind::ManageProfiles => SelectProfileMenuItem::ManageProfiles,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
match &self.kind {
|
||||
ProfileSearchItemKind::Profile { profile_name, .. } => {
|
||||
format!("Profile: {profile_name}")
|
||||
}
|
||||
ProfileSearchItemKind::ManageProfiles => MANAGE_PROFILES_LABEL.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{
|
||||
Element, Entity, EntityId, ModelHandle, SingletonEntity as _, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::execution_profiles::profiles::{
|
||||
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
|
||||
};
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::profiles::data_source::{
|
||||
ProfileSelectorDataSource, SelectProfileMenuItem,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineProfileSelectorEvent {
|
||||
SelectedProfile { profile_id: ClientProfileId },
|
||||
ManageProfiles,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct InlineProfileSelectorView {
|
||||
menu_view: ViewHandle<InlineMenuView<SelectProfileMenuItem>>,
|
||||
mixer: ModelHandle<SearchMixer<SelectProfileMenuItem>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
impl InlineProfileSelectorView {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| ProfileSelectorDataSource::new(terminal_view_id));
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<SelectProfileMenuItem>::new();
|
||||
mixer.add_sync_source(data_source, []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => match item {
|
||||
SelectProfileMenuItem::Profile { profile_id } => {
|
||||
ctx.emit(InlineProfileSelectorEvent::SelectedProfile {
|
||||
profile_id: *profile_id,
|
||||
});
|
||||
}
|
||||
SelectProfileMenuItem::ManageProfiles => {
|
||||
ctx.emit(InlineProfileSelectorEvent::ManageProfiles);
|
||||
}
|
||||
},
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineProfileSelectorEvent::Dismissed);
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. }
|
||||
| InlineMenuEvent::NoResults
|
||||
| InlineMenuEvent::TabChanged => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if model.as_ref(ctx).is_profile_selector() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: me.input_buffer_model.as_ref(ctx).current_value().to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else if model.as_ref(ctx).is_closed() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if !me.suggestions_mode_model.as_ref(ctx).is_profile_selector() {
|
||||
return;
|
||||
}
|
||||
|
||||
let InputBufferUpdateEvent {
|
||||
new_content,
|
||||
old_content,
|
||||
..
|
||||
} = event;
|
||||
if new_content == old_content {
|
||||
// No need to re-run the query if the content hasn't changed.
|
||||
return;
|
||||
}
|
||||
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: new_content.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&AIExecutionProfilesModel::handle(ctx),
|
||||
|me, _, event, ctx| {
|
||||
if !me.suggestions_mode_model.as_ref(ctx).is_profile_selector() {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
AIExecutionProfilesModelEvent::ProfileCreated
|
||||
| AIExecutionProfilesModelEvent::ProfileDeleted
|
||||
| AIExecutionProfilesModelEvent::ProfileUpdated(_) => {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
AIExecutionProfilesModelEvent::UpdatedActiveProfile { terminal_view_id } => {
|
||||
if *terminal_view_id != me.terminal_view_id {
|
||||
return;
|
||||
}
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&mixer, |me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
if !me.suggestions_mode_model.as_ref(ctx).is_profile_selector() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-highlight the active profile when no filter query is entered.
|
||||
if me.input_buffer_model.as_ref(ctx).current_value().is_empty() {
|
||||
let active_profile_id = *AIExecutionProfilesModel::as_ref(ctx)
|
||||
.active_profile(Some(me.terminal_view_id), ctx)
|
||||
.id();
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_first_where(
|
||||
|item| {
|
||||
matches!(
|
||||
item,
|
||||
SelectProfileMenuItem::Profile { profile_id }
|
||||
if *profile_id == active_profile_id
|
||||
)
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
terminal_view_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |view, ctx| view.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |view, ctx| view.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |view, ctx| view.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineProfileSelectorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineProfileSelectorView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineProfileSelectorView {
|
||||
type Event = InlineProfileSelectorEvent;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, Entity, ModelContext, ModelHandle, SingletonEntity as _};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::search::command_palette::warp_drive;
|
||||
use crate::search::data_source::{DataSourceSearchError, Query, QueryResult};
|
||||
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,
|
||||
};
|
||||
use crate::terminal::input::message_bar::Message;
|
||||
use crate::workflows::CloudWorkflow;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptPrompt {
|
||||
pub id: SyncId,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptPrompt {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::PromptsMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
Some(Message::new(default_navigation_message_items(&args)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PromptsMenuDataSource {
|
||||
warp_drive_data_source: ModelHandle<warp_drive::DataSource>,
|
||||
}
|
||||
|
||||
impl PromptsMenuDataSource {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Ideally this would be a full-text searching but full text searching is slow, and
|
||||
// currently its implementation is not well-setup for async use.
|
||||
//
|
||||
// TODO(zachbai): Revert to full-text search and make this an `AsyncDataSource`.
|
||||
let warp_drive_data_source = ctx.add_model(warp_drive::DataSource::new_fuzzy);
|
||||
Self {
|
||||
warp_drive_data_source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for PromptsMenuDataSource {
|
||||
type Action = AcceptPrompt;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let query_text = query.text.trim();
|
||||
|
||||
if query_text.is_empty() {
|
||||
let cloud_workflows = CloudModel::as_ref(app).get_all_active_workflows();
|
||||
|
||||
return Ok(cloud_workflows
|
||||
.filter(|workflow| !workflow.model().data.is_command_workflow())
|
||||
.map(|workflow| QueryResult::from(PromptSearchItem::from_workflow(workflow)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
// For single-character queries, use prefix matching on the name instead of fuzzy
|
||||
// search to avoid missing valid results while still filtering the list.
|
||||
if query_text.chars().count() == 1 {
|
||||
let query_char = query_text.chars().next().unwrap();
|
||||
let cloud_workflows = CloudModel::as_ref(app).get_all_active_workflows();
|
||||
|
||||
return Ok(cloud_workflows
|
||||
.filter(|workflow| {
|
||||
!workflow.model().data.is_command_workflow()
|
||||
&& workflow
|
||||
.model()
|
||||
.data
|
||||
.name_starts_with_char_ignore_case(query_char)
|
||||
})
|
||||
.map(|workflow| QueryResult::from(PromptSearchItem::from_workflow(workflow)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
self.warp_drive_data_source
|
||||
.as_ref(app)
|
||||
.search_workflows(query, true, false, app)
|
||||
.map(|results| {
|
||||
results
|
||||
.into_iter()
|
||||
.filter_map(|result| {
|
||||
let score = result.score();
|
||||
// Avoid spamming results with extremely weak matches.
|
||||
(score > OrderedFloat(25.0)).then(|| {
|
||||
let workflow = result.cloud_workflow;
|
||||
if workflow.model().data.is_command_workflow() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
PromptSearchItem::from_workflow(&workflow)
|
||||
.with_name_match_result(result.match_result.name_match_result)
|
||||
.with_score(score),
|
||||
))
|
||||
})?
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| {
|
||||
Box::new(DataSourceSearchError {
|
||||
message: e.to_string(),
|
||||
}) as DataSourceRunErrorWrapper
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PromptsMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PromptSearchItem {
|
||||
id: SyncId,
|
||||
name: String,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl PromptSearchItem {
|
||||
fn from_workflow(workflow: &CloudWorkflow) -> Self {
|
||||
Self {
|
||||
id: workflow.id,
|
||||
name: workflow.model().data.name().to_owned(),
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for PromptSearchItem {
|
||||
type Action = AcceptPrompt;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
|
||||
let icon = Icon::Prompt.to_warpui_icon(icon_color).finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
|
||||
let mut name_text =
|
||||
Text::new_inline(self.name.clone(), appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
name_text.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<warp_core::ui::theme::Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptPrompt { id: self.id }
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Prompt: {}", self.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod data_source;
|
||||
mod view;
|
||||
|
||||
pub use data_source::*;
|
||||
pub use view::*;
|
||||
@@ -0,0 +1,129 @@
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::prompts::{AcceptPrompt, PromptsMenuDataSource};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlinePromptsMenuEvent {
|
||||
SelectedPrompt { id: SyncId },
|
||||
}
|
||||
|
||||
pub struct InlinePromptsMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptPrompt>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptPrompt>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlinePromptsMenuView {
|
||||
pub fn new(
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(PromptsMenuDataSource::new);
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptPrompt>::new();
|
||||
mixer.add_sync_source(data_source, []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| {
|
||||
if let InlineMenuEvent::AcceptedItem { item, .. } = event {
|
||||
ctx.emit(InlinePromptsMenuEvent::SelectedPrompt { id: item.id });
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if model.as_ref(ctx).is_prompts_menu() {
|
||||
let query_text = me.input_buffer_model.as_ref(ctx).current_value().to_owned();
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else if model.as_ref(ctx).is_closed() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.suggestions_mode_model.as_ref(ctx).is_prompts_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: new_content.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlinePromptsMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlinePromptsMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlinePromptsMenuView {
|
||||
type Event = InlinePromptsMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Async data source for the inline repos menu.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use crate::ai::persisted_workspace::PersistedWorkspace;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{AsyncDataSource, BoxFuture, DataSourceRunErrorWrapper};
|
||||
use crate::terminal::input::repos::AcceptRepo;
|
||||
|
||||
pub struct RepoMenuDataSource;
|
||||
|
||||
impl RepoMenuDataSource {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncDataSource for RepoMenuDataSource {
|
||||
type Action = AcceptRepo;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> BoxFuture<'static, Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper>> {
|
||||
let workspace_paths: Vec<PathBuf> = PersistedWorkspace::as_ref(app)
|
||||
.workspaces()
|
||||
.map(|m| m.path)
|
||||
.collect();
|
||||
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
Box::pin(async move {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
use crate::terminal::input::repos::search_item::RepoSearchItem;
|
||||
use crate::util::git::get_repo_git_summary;
|
||||
|
||||
let futures: Vec<_> = workspace_paths
|
||||
.into_iter()
|
||||
.map(|path| async move {
|
||||
let summary = get_repo_git_summary(&path).await;
|
||||
RepoSearchItem::new(path, summary)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut items: Vec<RepoSearchItem> = futures::future::join_all(futures).await;
|
||||
items.sort_by(|a, b| a.display_name.cmp(&b.display_name));
|
||||
|
||||
let results: Vec<QueryResult<AcceptRepo>> = if query_text.is_empty() {
|
||||
items.into_iter().map(QueryResult::from).collect()
|
||||
} else {
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let match_result = fuzzy_match::match_indices_case_insensitive(
|
||||
&item.display_name,
|
||||
&query_text,
|
||||
)?;
|
||||
if match_result.score < 25 {
|
||||
return None;
|
||||
}
|
||||
Some(QueryResult::from(
|
||||
item.with_name_match_result(Some(match_result)),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
let _ = workspace_paths;
|
||||
let _ = query_text;
|
||||
Ok(vec![])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RepoMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Inline repo switcher menu showing indexed repos with git status.
|
||||
mod data_source;
|
||||
#[cfg(feature = "local_fs")]
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlineReposMenuEvent, InlineReposMenuView};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::common::disableable_message_item_color_overrides;
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
/// Action emitted when a repo is accepted in the inline repos menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptRepo {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptRepo {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::IndexedReposMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
let path = args
|
||||
.inline_menu_model
|
||||
.selected_item()
|
||||
.map(|item| item.path.clone());
|
||||
let has_path = path.is_some();
|
||||
|
||||
let (
|
||||
color_override_for_shortcuts_and_commands,
|
||||
bg_color_override_for_shortcuts_and_commands,
|
||||
) = disableable_message_item_color_overrides(!has_path, args.app);
|
||||
|
||||
items.push(
|
||||
MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
background_color: bg_color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: " cd to repo".into(),
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
],
|
||||
move |ctx| {
|
||||
if let Some(path) = path.clone() {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: AcceptRepo { path },
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
args.inline_menu_model.mouse_states().accept.clone(),
|
||||
)
|
||||
.with_is_disabled(!has_path),
|
||||
);
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! SearchItem implementation for repo menu items.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::theme::{AnsiColorIdentifier, Fill};
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use crate::search::SearchItem;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::repos::AcceptRepo;
|
||||
use crate::util::git::RepoGitSummary;
|
||||
|
||||
/// Search item for rendering a repo in the inline repo menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RepoSearchItem {
|
||||
pub path: PathBuf,
|
||||
pub display_name: String,
|
||||
git_summary: Option<RepoGitSummary>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
}
|
||||
|
||||
impl RepoSearchItem {
|
||||
pub fn new(path: PathBuf, git_summary: Option<RepoGitSummary>) -> Self {
|
||||
let display_name = repo_display_name(&path);
|
||||
Self {
|
||||
path,
|
||||
display_name,
|
||||
git_summary,
|
||||
name_match_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_display_name(repo_path: &Path) -> String {
|
||||
dirs::home_dir()
|
||||
.and_then(|home| repo_path.strip_prefix(&home).ok().map(|p| p.to_path_buf()))
|
||||
.map(|relative_path| format!("~/{}", relative_path.display()))
|
||||
.unwrap_or_else(|| repo_path.display().to_string())
|
||||
}
|
||||
|
||||
impl SearchItem for RepoSearchItem {
|
||||
type Action = AcceptRepo;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
|
||||
let icon = ConstrainedBox::new(Icon::Folder.to_warpui_icon(icon_color).finish())
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish();
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color =
|
||||
inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
// Build primary text: "~/path/to/repo" with optional " branch +N -N" suffix.
|
||||
let branch_suffix = self
|
||||
.git_summary
|
||||
.as_ref()
|
||||
.map(|s| format!(" {}", s.branch.to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let stats_suffix = self
|
||||
.git_summary
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
if s.lines_added > 0 || s.lines_removed > 0 {
|
||||
Some(format!(" +{} -{}", s.lines_added, s.lines_removed))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let name_len = self.display_name.len();
|
||||
let mut full_text =
|
||||
String::with_capacity(name_len + branch_suffix.len() + stats_suffix.len());
|
||||
full_text.push_str(&self.display_name);
|
||||
full_text.push_str(&branch_suffix);
|
||||
full_text.push_str(&stats_suffix);
|
||||
|
||||
let mut name_text = Text::new_inline(full_text, appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into_solid())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
// Highlight fuzzy match indices on the name portion.
|
||||
if let Some(match_result) = &self.name_match_result {
|
||||
if !match_result.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Style branch suffix in secondary color.
|
||||
let branch_end = name_len + branch_suffix.len();
|
||||
if !branch_suffix.is_empty() {
|
||||
let branch_range: Vec<usize> = (name_len..branch_end).collect();
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_foreground_color(secondary_text_color.into_solid()),
|
||||
branch_range,
|
||||
);
|
||||
}
|
||||
|
||||
// Inline diff stats colored green/red right after branch.
|
||||
if !stats_suffix.is_empty() {
|
||||
let terminal_colors = &theme.terminal_colors().normal;
|
||||
let add_color = AnsiColorIdentifier::Green
|
||||
.to_ansi_color(terminal_colors)
|
||||
.into();
|
||||
let remove_color = AnsiColorIdentifier::Red
|
||||
.to_ansi_color(terminal_colors)
|
||||
.into();
|
||||
|
||||
// stats_suffix is " +N -N"; find the boundary between +N and -N.
|
||||
let stats_start = branch_end;
|
||||
let add_part_len = stats_suffix.find(" -").unwrap_or(stats_suffix.len());
|
||||
let add_range: Vec<usize> = (stats_start..(stats_start + add_part_len)).collect();
|
||||
let remove_range: Vec<usize> =
|
||||
((stats_start + add_part_len)..(stats_start + stats_suffix.len())).collect();
|
||||
|
||||
name_text = name_text
|
||||
.with_single_highlight(Highlight::new().with_foreground_color(add_color), add_range)
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_foreground_color(remove_color),
|
||||
remove_range,
|
||||
);
|
||||
}
|
||||
|
||||
name_text.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.name_match_result
|
||||
.as_ref()
|
||||
.map(|m| OrderedFloat(m.score as f64))
|
||||
.unwrap_or(OrderedFloat(f64::MIN))
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> AcceptRepo {
|
||||
AcceptRepo {
|
||||
path: self.path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> AcceptRepo {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Indexed repository: {}", self.display_name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Inline repos menu view for switching between indexed repos.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::{AddAsyncSourceOptions, SearchMixer};
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::repos::data_source::RepoMenuDataSource;
|
||||
use crate::terminal::input::repos::AcceptRepo;
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
/// Events emitted by InlineReposMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineReposMenuEvent {
|
||||
/// User accepted a repo (hit enter).
|
||||
NavigateToRepo { path: PathBuf },
|
||||
/// User dismissed the menu.
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct InlineReposMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptRepo>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptRepo>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlineReposMenuView {
|
||||
pub fn new(
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| RepoMenuDataSource::new());
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptRepo>::new();
|
||||
mixer.add_async_source(
|
||||
data_source.clone(),
|
||||
[QueryFilter::Repos],
|
||||
AddAsyncSourceOptions {
|
||||
debounce_interval: None,
|
||||
run_in_zero_state: true,
|
||||
run_when_unfiltered: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer.run_query(repos_query(""), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item,
|
||||
cmd_or_ctrl_shift_enter: _,
|
||||
} => {
|
||||
ctx.emit(InlineReposMenuEvent::NavigateToRepo {
|
||||
path: item.path.clone(),
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. }
|
||||
| InlineMenuEvent::NoResults
|
||||
| InlineMenuEvent::TabChanged => (),
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineReposMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if input_suggestions_model.as_ref(ctx).is_repos_menu() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
repos_query(me.input_buffer_model.as_ref(ctx).current_value()),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_repos_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(repos_query(new_content), ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(cmd_or_ctrl_enter, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Query that includes the Repos filter so the async source runs.
|
||||
fn repos_query(text: &str) -> Query {
|
||||
Query {
|
||||
text: text.to_owned(),
|
||||
filters: HashSet::from([QueryFilter::Repos]),
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineReposMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineReposMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineReposMenuView {
|
||||
type Event = InlineReposMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Data source for the rewind menu.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ai::agent::action_result::RequestFileEditsResult;
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentExchangeId, AIAgentInput};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::rewind::search_item::RewindSearchItem;
|
||||
|
||||
/// Action emitted when a rewind point is selected.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SelectRewindPoint {
|
||||
/// The exchange ID to rewind to, or None for "Current" (dismiss without rewinding).
|
||||
pub exchange_id: Option<AIAgentExchangeId>,
|
||||
}
|
||||
|
||||
/// Information about file changes for a rewind point.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileChangesInfo {
|
||||
pub lines_added: usize,
|
||||
pub lines_removed: usize,
|
||||
}
|
||||
|
||||
pub struct RewindDataSource {
|
||||
conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
impl RewindDataSource {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self { conversation_id }
|
||||
}
|
||||
|
||||
pub fn set_conversation_id(&mut self, conversation_id: AIConversationId) {
|
||||
self.conversation_id = conversation_id;
|
||||
}
|
||||
|
||||
/// Get file changes info for a "block" of exchanges (from user query to next user query)
|
||||
fn get_file_changes_for_block(
|
||||
exchanges: &[&crate::ai::agent::AIAgentExchange],
|
||||
) -> FileChangesInfo {
|
||||
let mut file_paths: HashSet<String> = HashSet::new();
|
||||
let mut total_lines_added: usize = 0;
|
||||
let mut total_lines_removed: usize = 0;
|
||||
|
||||
for exchange in exchanges {
|
||||
for input in &exchange.input {
|
||||
if let Some(action_result) = input.action_result() {
|
||||
if let AIAgentActionResultType::RequestFileEdits(
|
||||
RequestFileEditsResult::Success {
|
||||
updated_files,
|
||||
lines_added,
|
||||
lines_removed,
|
||||
..
|
||||
},
|
||||
) = &action_result.result
|
||||
{
|
||||
total_lines_added += lines_added;
|
||||
total_lines_removed += lines_removed;
|
||||
|
||||
for updated_file in updated_files {
|
||||
file_paths.insert(updated_file.file_context.file_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if file_paths.is_empty() {
|
||||
return FileChangesInfo::default();
|
||||
}
|
||||
|
||||
FileChangesInfo {
|
||||
lines_added: total_lines_added,
|
||||
lines_removed: total_lines_removed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for RewindDataSource {
|
||||
type Action = SelectRewindPoint;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(&self.conversation_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let all_exchanges = conversation.root_task_exchanges().collect_vec();
|
||||
|
||||
// Find indices of exchanges with user queries
|
||||
let user_query_indices: Vec<usize> = all_exchanges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, ex)| ex.has_user_query().then_some(idx))
|
||||
.collect();
|
||||
|
||||
let search_query = query.text.trim().to_lowercase();
|
||||
|
||||
// Build rewind points in chronological order (oldest first)
|
||||
let mut results = Vec::new();
|
||||
|
||||
for &exchange_idx in user_query_indices.iter() {
|
||||
let exchange = &all_exchanges[exchange_idx];
|
||||
let query_text = exchange
|
||||
.input
|
||||
.iter()
|
||||
.find_map(AIAgentInput::user_query)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Find the end of this "block" - either the next user query or end of exchanges
|
||||
let next_user_query_idx = user_query_indices
|
||||
.iter()
|
||||
.find(|&&idx| idx > exchange_idx)
|
||||
.copied()
|
||||
.unwrap_or(all_exchanges.len());
|
||||
|
||||
// Get file changes for all exchanges in this block
|
||||
let exchanges_in_block = &all_exchanges[exchange_idx..next_user_query_idx];
|
||||
let file_changes = Self::get_file_changes_for_block(exchanges_in_block);
|
||||
|
||||
// Filter by search query if present
|
||||
if !search_query.is_empty() {
|
||||
let match_result =
|
||||
fuzzy_match::match_indices_case_insensitive(&query_text, &search_query);
|
||||
if let Some(match_result) = match_result {
|
||||
results.push(QueryResult::from(
|
||||
RewindSearchItem::new_rewind_point(exchange.id, query_text, file_changes)
|
||||
.with_query_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
results.push(QueryResult::from(RewindSearchItem::new_rewind_point(
|
||||
exchange.id,
|
||||
query_text,
|
||||
file_changes,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score if filtering
|
||||
if !search_query.is_empty() {
|
||||
results.sort_by_key(|b| std::cmp::Reverse(b.score()));
|
||||
} else {
|
||||
// Add "Current" as the last item (appears at bottom, closest to input)
|
||||
results.push(QueryResult::from(RewindSearchItem::new_current()));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RewindDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Inline menu for selecting rewind points in a conversation.
|
||||
//! Used by the `/rewind` slash command to let users select which point to rewind to.
|
||||
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::SelectRewindPoint;
|
||||
pub use view::{RewindMenuEvent, RewindMenuView};
|
||||
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
impl InlineMenuAction for SelectRewindPoint {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::RewindMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let mut items = vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text("rewind"),
|
||||
];
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! SearchItem implementation for rewind menu items.
|
||||
//! Renders two lines: query text and code changes summary.
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::{add_color, remove_color};
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles::{
|
||||
font_size, icon_color, item_background, menu_background_color, primary_text_color, ICON_MARGIN,
|
||||
ITEM_CORNER_RADIUS, ITEM_HORIZONTAL_PADDING,
|
||||
};
|
||||
use crate::terminal::input::rewind::data_source::{FileChangesInfo, SelectRewindPoint};
|
||||
|
||||
const ICON_PADDING: f32 = 4.;
|
||||
|
||||
/// Search item for rendering a rewind point in the rewind menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindSearchItem {
|
||||
/// The exchange ID to rewind to, or None for "Current".
|
||||
exchange_id: Option<AIAgentExchangeId>,
|
||||
query_text: String,
|
||||
file_changes: FileChangesInfo,
|
||||
query_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
is_current: bool,
|
||||
}
|
||||
|
||||
impl RewindSearchItem {
|
||||
/// Create a "Current" item that dismisses the menu without rewinding.
|
||||
pub fn new_current() -> Self {
|
||||
Self {
|
||||
exchange_id: None,
|
||||
query_text: "Current".to_string(),
|
||||
file_changes: FileChangesInfo::default(),
|
||||
query_match_result: None,
|
||||
score: OrderedFloat(0.0),
|
||||
is_current: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a rewind point item.
|
||||
pub fn new_rewind_point(
|
||||
exchange_id: AIAgentExchangeId,
|
||||
query_text: String,
|
||||
file_changes: FileChangesInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
exchange_id: Some(exchange_id),
|
||||
query_text,
|
||||
file_changes,
|
||||
query_match_result: None,
|
||||
score: OrderedFloat(0.0),
|
||||
is_current: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_query_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.query_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
|
||||
fn has_code_changes(&self) -> bool {
|
||||
self.file_changes.lines_added > 0 || self.file_changes.lines_removed > 0
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for RewindSearchItem {
|
||||
type Action = SelectRewindPoint;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = icon_color(appearance);
|
||||
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(Icon::ClockRewind.to_warpui_icon(color).finish())
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(ICON_PADDING)
|
||||
.with_background(coloru_with_opacity(color.into(), 10))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ITEM_CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Container::new(icon).with_margin_right(ICON_MARGIN).finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let background = menu_background_color(app);
|
||||
let secondary_font_size = font_size(appearance) - 2.;
|
||||
|
||||
// Line 1: Query text with fuzzy match highlighting
|
||||
let mut query_text = Text::new_inline(
|
||||
self.query_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size(appearance),
|
||||
)
|
||||
.with_color(primary_text_color(theme, background.into()).into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(match_result) = &self.query_match_result {
|
||||
if !match_result.matched_indices.is_empty() {
|
||||
query_text = query_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Line 2: Code changes summary
|
||||
let secondary_text_color: warpui::color::ColorU =
|
||||
theme.sub_text_color(theme.surface_1()).into();
|
||||
|
||||
let changes_element: Box<dyn Element> = if self.is_current {
|
||||
// "Current" item shows "No code to be restored"
|
||||
Text::new_inline(
|
||||
"No code to be restored".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
secondary_font_size,
|
||||
)
|
||||
.with_color(secondary_text_color)
|
||||
.finish()
|
||||
} else if self.has_code_changes() {
|
||||
// Format: "+{added} -{removed}"
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
row.add_child(
|
||||
Text::new_inline(
|
||||
format!("+{}", self.file_changes.lines_added),
|
||||
appearance.ui_font_family(),
|
||||
secondary_font_size,
|
||||
)
|
||||
.with_color(add_color(appearance))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
row.add_child(
|
||||
Text::new_inline(
|
||||
format!(" -{}", self.file_changes.lines_removed),
|
||||
appearance.ui_font_family(),
|
||||
secondary_font_size,
|
||||
)
|
||||
.with_color(remove_color(appearance))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
row.finish()
|
||||
} else {
|
||||
Text::new_inline(
|
||||
"No code to be restored".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
secondary_font_size,
|
||||
)
|
||||
.with_color(secondary_text_color)
|
||||
.finish()
|
||||
};
|
||||
|
||||
// Stack the two lines vertically
|
||||
let content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(query_text.finish())
|
||||
.with_child(Container::new(changes_element).with_margin_top(2.).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(content)
|
||||
.with_padding_right(ITEM_HORIZONTAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
SelectRewindPoint {
|
||||
exchange_id: self.exchange_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
if self.is_current {
|
||||
"Current state (no rewind)".to_string()
|
||||
} else if self.has_code_changes() {
|
||||
format!(
|
||||
"Rewind to: {} (+{} -{})",
|
||||
self.query_text, self.file_changes.lines_added, self.file_changes.lines_removed
|
||||
)
|
||||
} else {
|
||||
format!("Rewind to: {} (no code changes)", self.query_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! View for the rewind menu.
|
||||
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::rewind::data_source::{RewindDataSource, SelectRewindPoint};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
/// Events emitted by RewindMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RewindMenuEvent {
|
||||
/// User accepted a rewind point (hit enter).
|
||||
/// If exchange_id is None, user selected "Current" (dismiss without rewinding).
|
||||
AcceptedRewindPoint {
|
||||
exchange_id: Option<AIAgentExchangeId>,
|
||||
},
|
||||
/// User dismissed the menu (escape or click).
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct RewindMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<SelectRewindPoint>>,
|
||||
data_source: ModelHandle<RewindDataSource>,
|
||||
mixer: ModelHandle<SearchMixer<SelectRewindPoint>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
}
|
||||
|
||||
impl RewindMenuView {
|
||||
pub fn new(
|
||||
conversation_id: AIConversationId,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| RewindDataSource::new(conversation_id));
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<SelectRewindPoint>::new();
|
||||
mixer.add_sync_source(data_source.clone(), []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => {
|
||||
ctx.emit(RewindMenuEvent::AcceptedRewindPoint {
|
||||
exchange_id: item.exchange_id,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } => {
|
||||
// Could emit a preview event if needed
|
||||
}
|
||||
InlineMenuEvent::NoResults | InlineMenuEvent::TabChanged => {}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(RewindMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if let Some(conversation_id) =
|
||||
input_suggestions_model.as_ref(ctx).rewind_conversation_id()
|
||||
{
|
||||
me.data_source.update(ctx, |ds, _| {
|
||||
ds.set_conversation_id(conversation_id);
|
||||
});
|
||||
me.refresh_results("", ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_rewind_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.refresh_results(new_content, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
data_source,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&self, search_query: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: search_query.to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for RewindMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"RewindMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RewindMenuView {
|
||||
type Event = RewindMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::{SkillProvider, SkillReference, SkillScope};
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _,
|
||||
};
|
||||
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
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,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptSkill {
|
||||
pub skill_name: String,
|
||||
pub skill_reference: SkillReference,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptSkill {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::SkillMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
// If no item is selected, show "No skills found" message with escape hint
|
||||
if args.inline_menu_model.selected_item().is_none() {
|
||||
return Some(Message::new(vec![
|
||||
MessageItem::text("No skills found"),
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to dismiss"),
|
||||
]));
|
||||
}
|
||||
|
||||
// Otherwise show default navigation hints
|
||||
Some(Message::new(default_navigation_message_items(&args)))
|
||||
}
|
||||
|
||||
// No details panel - we show inline descriptions instead
|
||||
}
|
||||
|
||||
/// Event emitted when available skills may have changed.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct UpdatedAvailableSkills;
|
||||
|
||||
pub struct SkillSelectorDataSource {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
terminal_view_id: EntityId,
|
||||
/// Whether bundled skills should be included in results.
|
||||
/// False for `/open-skill` (bundled skills can't be edited), true for `/skills` (they can be invoked).
|
||||
include_bundled: bool,
|
||||
}
|
||||
|
||||
impl SkillSelectorDataSource {
|
||||
pub fn new(
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
active_session,
|
||||
terminal_view_id,
|
||||
include_bundled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the supported skill providers for the active CLI agent, or `None` if
|
||||
/// CLI agent input is not open.
|
||||
fn active_cli_agent_providers(&self, app: &AppContext) -> Option<&'static [SkillProvider]> {
|
||||
CLIAgentSessionsModel::as_ref(app)
|
||||
.session(self.terminal_view_id)
|
||||
.filter(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
|
||||
.map(|s| s.agent.supported_skill_providers())
|
||||
}
|
||||
|
||||
pub fn set_include_bundled(&mut self, include_bundled: bool) {
|
||||
self.include_bundled = include_bundled;
|
||||
}
|
||||
|
||||
/// Get the current working directory from the active session
|
||||
fn get_current_working_directory(&self, app: &AppContext) -> Option<PathBuf> {
|
||||
self.active_session
|
||||
.as_ref(app)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for SkillSelectorDataSource {
|
||||
type Action = AcceptSkill;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> 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);
|
||||
|
||||
// Filter out bundled skills when in open mode, since they cannot be opened.
|
||||
// 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
|
||||
// provider even when the skill also exists in the CLI agent's folder.
|
||||
let skill_manager = SkillManager::as_ref(app);
|
||||
let skills: Vec<_> = skills
|
||||
.into_iter()
|
||||
.filter(|skill| {
|
||||
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(_))
|
||||
}
|
||||
})
|
||||
.map(|mut skill| {
|
||||
// When a CLI agent is active, re-map the provider to the best
|
||||
// supported one so the icon reflects the agent's native provider
|
||||
// rather than the global dedup winner.
|
||||
if let Some(providers) = &cli_agent_providers {
|
||||
skill.provider = skill_manager.best_supported_provider(&skill, providers);
|
||||
}
|
||||
skill
|
||||
})
|
||||
.collect();
|
||||
|
||||
let query_text = query.text.trim();
|
||||
if query_text.is_empty() {
|
||||
return Ok(skills
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
QueryResult::from(SkillSearchItem::new(
|
||||
skill.name,
|
||||
skill.reference,
|
||||
skill.description,
|
||||
skill.scope,
|
||||
skill.provider,
|
||||
skill.icon_override,
|
||||
))
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
Ok(skills
|
||||
.into_iter()
|
||||
.filter_map(|skill| {
|
||||
let match_result = match_indices_case_insensitive(skill.name.as_str(), query_text)?;
|
||||
// Avoid spamming results with extremely weak matches.
|
||||
if query_text.len() > 1 && match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
Some(QueryResult::from(
|
||||
SkillSearchItem::new(
|
||||
skill.name,
|
||||
skill.reference,
|
||||
skill.description,
|
||||
skill.scope,
|
||||
skill.provider,
|
||||
skill.icon_override,
|
||||
)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SkillSelectorDataSource {
|
||||
type Event = UpdatedAvailableSkills;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SkillSearchItem {
|
||||
skill_name: String,
|
||||
skill_reference: SkillReference,
|
||||
skill_description: String,
|
||||
scope: SkillScope,
|
||||
provider: SkillProvider,
|
||||
icon_override: Option<Icon>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl SkillSearchItem {
|
||||
fn new(
|
||||
skill_name: String,
|
||||
skill_reference: SkillReference,
|
||||
skill_description: String,
|
||||
scope: SkillScope,
|
||||
provider: SkillProvider,
|
||||
icon_override: Option<Icon>,
|
||||
) -> Self {
|
||||
Self {
|
||||
skill_name,
|
||||
skill_reference,
|
||||
skill_description,
|
||||
scope,
|
||||
provider,
|
||||
icon_override,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed width for the skill name column (similar to slash commands).
|
||||
fn skill_name_column_width(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
// Use a reasonable fixed width for skill names
|
||||
app.font_cache().em_width(
|
||||
appearance.monospace_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
) * 20.0 // Allow space for skill names like "/analyze-code"
|
||||
+ 32.0
|
||||
}
|
||||
|
||||
impl SearchItem for SkillSearchItem {
|
||||
type Action = AcceptSkill;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
|
||||
// Use icon_override if set (e.g. Figma skills), otherwise derive from provider.
|
||||
let icon = if let Some(override_icon) = self.icon_override {
|
||||
override_icon.to_warpui_icon(icon_color).finish()
|
||||
} else {
|
||||
self.provider
|
||||
.icon()
|
||||
.to_warpui_icon(self.provider.icon_fill(icon_color))
|
||||
.finish()
|
||||
};
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_color = inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
// Create row layout for inline text
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
// Skill name with fuzzy match highlighting
|
||||
let mut name_text = Text::new_inline(
|
||||
self.skill_name.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
ConstrainedBox::new(name_text.finish())
|
||||
.with_width(skill_name_column_width(app))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Description and optional "Project Skill" badge
|
||||
// The description should truncate first, badge stays fixed size
|
||||
// We wrap the whole description_row in Shrinkable to give it a bounded constraint
|
||||
let mut description_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if !self.skill_description.is_empty() {
|
||||
let description_text = Text::new_inline(
|
||||
self.skill_description.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
// Use Shrinkable so description truncates before the badge
|
||||
description_row.add_child(Shrinkable::new(1., description_text.finish()).finish());
|
||||
}
|
||||
|
||||
// "Project Skill" badge for project skills (placed after description)
|
||||
if self.scope == SkillScope::Project {
|
||||
let badge_font_size = font_size - 4.0;
|
||||
// Badge text uses disabled_text_color (40% opacity) per Figma #6d7276
|
||||
let badge_text_color =
|
||||
inline_styles::disabled_text_color(theme, background_color.into());
|
||||
let badge_text = Text::new_inline(
|
||||
"Project Skill".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
badge_font_size,
|
||||
)
|
||||
.with_color(badge_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
let badge = Container::new(badge_text.finish())
|
||||
.with_horizontal_padding(6.0)
|
||||
.with_vertical_padding(2.0)
|
||||
.with_background(theme.surface_overlay_1())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0)))
|
||||
.with_margin_left(8.0)
|
||||
.finish();
|
||||
|
||||
description_row.add_child(badge);
|
||||
}
|
||||
|
||||
// Wrap in Shrinkable to provide bounded constraint for inner flexible children
|
||||
row.add_child(Shrinkable::new(1., description_row.finish()).finish());
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
// No details panel - we show inline descriptions instead
|
||||
fn render_details(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptSkill {
|
||||
skill_name: self.skill_name.clone(),
|
||||
skill_reference: self.skill_reference.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Skill: {}", self.skill_name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod data_source;
|
||||
mod view;
|
||||
|
||||
pub use data_source::{AcceptSkill, SkillSelectorDataSource, UpdatedAvailableSkills};
|
||||
pub use view::{InlineSkillSelectorEvent, InlineSkillSelectorView};
|
||||
@@ -0,0 +1,166 @@
|
||||
use ai::skills::SkillReference;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::skills::data_source::{
|
||||
AcceptSkill, SkillSelectorDataSource, UpdatedAvailableSkills,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use warpui::EntityId;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineSkillSelectorEvent {
|
||||
SelectedSkill {
|
||||
skill_name: String,
|
||||
skill_reference: SkillReference,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct InlineSkillSelectorView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptSkill>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptSkill>>,
|
||||
data_source: ModelHandle<SkillSelectorDataSource>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlineSkillSelectorView {
|
||||
pub fn new(
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx
|
||||
.add_model(|ctx| SkillSelectorDataSource::new(active_session, terminal_view_id, ctx));
|
||||
|
||||
let mixer = ctx.add_model(|_| {
|
||||
let mut mixer = SearchMixer::<AcceptSkill>::new();
|
||||
mixer.add_sync_source(data_source.clone(), []);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| {
|
||||
if let InlineMenuEvent::AcceptedItem { item, .. } = event {
|
||||
ctx.emit(InlineSkillSelectorEvent::SelectedSkill {
|
||||
skill_name: item.skill_name.clone(),
|
||||
skill_reference: item.skill_reference.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if model.as_ref(ctx).is_skill_menu() {
|
||||
let query_text = me.input_buffer_model.as_ref(ctx).current_value().to_owned();
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else if model.as_ref(ctx).is_closed() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.suggestions_mode_model.as_ref(ctx).is_skill_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: new_content.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&mixer, |_me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
// No special handling needed for skills - just re-render
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&data_source, |me, _, _: &UpdatedAvailableSkills, ctx| {
|
||||
// Re-run the query when skills change (e.g., pwd changed)
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
data_source,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets whether bundled skills are included in results.
|
||||
/// Should be called before opening the menu.
|
||||
pub fn set_include_bundled(&self, include_bundled: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.data_source.update(ctx, |ds, _| {
|
||||
ds.set_include_bundled(include_bundled);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineSkillSelectorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineSkillSelectorView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineSkillSelectorView {
|
||||
type Event = InlineSkillSelectorEvent;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
use ai::skills::SkillReference;
|
||||
use input_classifier::InputType;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::{BlocklistAIInputEvent, BlocklistAIInputModel};
|
||||
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)]
|
||||
pub struct UpdatedSlashCommandModel {
|
||||
/// The state before the update.
|
||||
pub old_state: SlashCommandEntryState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedCommand {
|
||||
/// The command in the input.
|
||||
pub command: StaticCommand,
|
||||
|
||||
/// The space-delimited argument to the command, if any. Does not include the leading space.
|
||||
///
|
||||
/// If there is no trailing space after the command, then `None`.
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
|
||||
/// A detected skill command in the input buffer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedSkillCommand {
|
||||
/// Either a path or a bundled_skill_id which uniquely identifies the skill
|
||||
pub reference: SkillReference,
|
||||
|
||||
/// The skill name (without the leading '/').
|
||||
pub name: String,
|
||||
|
||||
/// The space-delimited argument to the skill command (the user's prompt).
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SlashCommandEntryState {
|
||||
/// The input contents have nothing to do with a slash command.
|
||||
None,
|
||||
/// '/' and a slash command is being composed.
|
||||
Composing {
|
||||
/// The suffix in the input after '/'.
|
||||
filter: String,
|
||||
},
|
||||
/// A valid slash command is entered in the input.
|
||||
SlashCommand(DetectedCommand),
|
||||
/// A valid skill command is entered in the input.
|
||||
SkillCommand(DetectedSkillCommand),
|
||||
/// Slash commands are disabled until the buffer is cleared.
|
||||
///
|
||||
/// In this state, buffer content is not parsed for slash commands.
|
||||
DisabledUntilEmptyBuffer,
|
||||
}
|
||||
|
||||
impl SlashCommandEntryState {
|
||||
pub fn detected_command(&self) -> Option<&StaticCommand> {
|
||||
match self {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
Some(&detected_command.command)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this state has a detected slash command.
|
||||
pub fn is_detected_command(&self) -> bool {
|
||||
matches!(self, Self::SlashCommand(_))
|
||||
}
|
||||
|
||||
/// Returns `true` if a slash command or skill command has been detected.
|
||||
pub fn is_detected_command_or_skill(&self) -> bool {
|
||||
matches!(self, Self::SlashCommand(_) | Self::SkillCommand(_))
|
||||
}
|
||||
|
||||
/// Returns the byte length of the command prefix that should be highlighted
|
||||
/// in the input buffer, or `None` if no command/skill is detected.
|
||||
pub fn command_prefix_highlight_len(&self, buffer_text: &str) -> Option<usize> {
|
||||
match self {
|
||||
SlashCommandEntryState::SlashCommand(detected) => buffer_text
|
||||
.starts_with(detected.command.name)
|
||||
.then_some(detected.command.name.len()),
|
||||
SlashCommandEntryState::SkillCommand(detected) => {
|
||||
// Skill name doesn't include the leading '/', so we prefix it for matching.
|
||||
let prefix_len = 1 + detected.name.len();
|
||||
buffer_text
|
||||
.get(..prefix_len)
|
||||
.is_some_and(|p| p.starts_with('/') && p[1..] == *detected.name)
|
||||
.then_some(prefix_len)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::DisabledUntilEmptyBuffer => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_disabled(&self) -> bool {
|
||||
matches!(self, Self::DisabledUntilEmptyBuffer)
|
||||
}
|
||||
|
||||
fn pending_command(&self) -> Option<&String> {
|
||||
match self {
|
||||
SlashCommandEntryState::Composing { filter } => Some(filter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SlashCommandModel {
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
ai_input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
state: SlashCommandEntryState,
|
||||
data_source: ModelHandle<SlashCommandDataSource>,
|
||||
}
|
||||
|
||||
impl SlashCommandModel {
|
||||
pub fn new(
|
||||
buffer_model: &ModelHandle<InputBufferModel>,
|
||||
ai_input_model: &ModelHandle<BlocklistAIInputModel>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
data_source: ModelHandle<SlashCommandDataSource>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(buffer_model, |me, event, ctx| {
|
||||
me.handle_input_buffer_update(event, ctx);
|
||||
});
|
||||
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
// In the old modality, slash commands are disabled in locked shell mode.
|
||||
//
|
||||
// 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 {
|
||||
BlocklistAIInputEvent::InputTypeChanged { config }
|
||||
| BlocklistAIInputEvent::LockChanged { config } => {
|
||||
if config.is_locked {
|
||||
if config.is_shell() && !me.state.is_disabled() {
|
||||
let old_state = std::mem::replace(
|
||||
&mut me.state,
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer,
|
||||
);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
} else if !config.is_shell()
|
||||
&& me.input_buffer_model.as_ref(ctx).current_value().is_empty()
|
||||
{
|
||||
let old_state =
|
||||
std::mem::replace(&mut me.state, SlashCommandEntryState::None);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
input_buffer_model: buffer_model.clone(),
|
||||
ai_input_model: ai_input_model.clone(),
|
||||
active_session,
|
||||
data_source,
|
||||
state: SlashCommandEntryState::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by SlashCommandsMenu when menu is dismissed.
|
||||
/// Only `UserEscape` blocks future execution; `NoResults` allows it.
|
||||
pub fn disable(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.state.is_disabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_input = self.input_buffer_model.as_ref(ctx).current_value();
|
||||
if current_input.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// In the old modality, the input mode is always set to AI mode when a slash command
|
||||
// is being composed. We interpret slash command menu dismissal as intent to execute a
|
||||
// shell command.
|
||||
//
|
||||
// In the new modality, we don't implicitly tie slash command composition to a specific
|
||||
// input mode, so we shouldn't change the input mode based on slash command disablement.
|
||||
if !FeatureFlag::AgentView.is_enabled()
|
||||
&& !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);
|
||||
});
|
||||
}
|
||||
|
||||
let old_state = std::mem::replace(
|
||||
&mut self.state,
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer,
|
||||
);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
}
|
||||
|
||||
/// Returns whether slash command execution should be allowed.
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.state.is_disabled()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &SlashCommandEntryState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Parses `text` into a `SlashCommandEntryState` without mutating the
|
||||
/// model or emitting events.
|
||||
/// Use this when you have a prompt string and need to know whether it is
|
||||
/// a slash command, skill command, or plain text.
|
||||
pub fn detect_command(&self, text: &str, ctx: &AppContext) -> SlashCommandEntryState {
|
||||
if !text.starts_with('/') {
|
||||
return SlashCommandEntryState::None;
|
||||
}
|
||||
if let Some(detected) = self.data_source.as_ref(ctx).parse_slash_command(text) {
|
||||
return SlashCommandEntryState::SlashCommand(detected);
|
||||
}
|
||||
if let Some(detected) = self.detect_skill_command(text, ctx) {
|
||||
return SlashCommandEntryState::SkillCommand(detected);
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
}
|
||||
|
||||
/// Detects whether `buffer` matches a known skill command.
|
||||
/// Accepts `&AppContext` so it can be called outside a model update.
|
||||
fn detect_skill_command(&self, buffer: &str, ctx: &AppContext) -> Option<DetectedSkillCommand> {
|
||||
let (possible_command, possible_argument) =
|
||||
if let Some((command, argument)) = buffer.split_once(" ") {
|
||||
(command, Some(argument.to_owned()))
|
||||
} else {
|
||||
(buffer, None)
|
||||
};
|
||||
|
||||
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 skills = SkillManager::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_skills_for_working_directory(cwd_path, ctx);
|
||||
|
||||
let matched_skill = skills.into_iter().find(|skill| skill.name == skill_name)?;
|
||||
|
||||
Some(DetectedSkillCommand {
|
||||
reference: matched_skill.reference,
|
||||
name: matched_skill.name,
|
||||
argument: possible_argument,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_input_buffer_update(
|
||||
&mut self,
|
||||
event: &InputBufferUpdateEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// AI-off is no longer a blanket disable: AI-dependent commands are filtered out
|
||||
// of `active_commands` via `Availability::AI_ENABLED`, so parsing still works for
|
||||
// non-AI commands like `/open-file`.
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
let ai_input_model = self.ai_input_model.as_ref(ctx);
|
||||
if ai_input_model.is_input_type_locked() && !ai_input_model.is_ai_input_enabled() {
|
||||
if !self.state.is_disabled() {
|
||||
let old_state = std::mem::replace(
|
||||
&mut self.state,
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer,
|
||||
);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if !self.data_source.as_ref(ctx).is_agent_view_active(ctx)
|
||||
&& !self.data_source.as_ref(ctx).is_cli_agent_input_open(ctx)
|
||||
&& !*InputSettings::as_ref(ctx)
|
||||
.enable_slash_commands_in_terminal
|
||||
.value()
|
||||
&& !self.state.is_disabled()
|
||||
{
|
||||
let old_state = std::mem::replace(
|
||||
&mut self.state,
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer,
|
||||
);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
return;
|
||||
}
|
||||
|
||||
let InputBufferUpdateEvent {
|
||||
new_content: new,
|
||||
old_content: old,
|
||||
} = &event;
|
||||
|
||||
if new.is_empty() {
|
||||
// The buffer was cleared, so reset state.
|
||||
let old_state = std::mem::replace(&mut self.state, SlashCommandEntryState::None);
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
return;
|
||||
}
|
||||
|
||||
// If the state is disabled but the buffer now starts with '/', re-evaluate.
|
||||
// This handles the case where the user types a query with '/' (disabling slash commands),
|
||||
// then edits the buffer to insert '/plan ' at the beginning.
|
||||
let did_add_slash = new.starts_with('/') && !old.starts_with('/');
|
||||
if self.state.is_disabled() && !did_add_slash {
|
||||
return;
|
||||
}
|
||||
|
||||
let old_state = self.state.clone();
|
||||
match self.detect_command(new, ctx) {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
if let SlashCommandEntryState::SlashCommand(old_detected_command) = &self.state {
|
||||
if *old_detected_command == detected_command {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::AgentView.is_enabled()
|
||||
|| detected_command.command.auto_enter_ai_mode
|
||||
{
|
||||
// In the old modality, when there is a detected slash command, the input _must_ be in
|
||||
// AI mode; we don't respect `StaticCommand::auto_enter_ai_mode = false`. That field is
|
||||
// only used in the new modality.
|
||||
//
|
||||
// The fact that we've even detected a command implies that the input mode is in AI
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
self.state = SlashCommandEntryState::SlashCommand(detected_command);
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
if let SlashCommandEntryState::SkillCommand(old_detected_skill) = &self.state {
|
||||
if *old_detected_skill == detected_skill {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skill commands always require AI mode
|
||||
self.ai_input_model.update(ctx, |input_model, ctx| {
|
||||
input_model.set_input_type(InputType::AI, ctx);
|
||||
});
|
||||
self.state = SlashCommandEntryState::SkillCommand(detected_skill);
|
||||
}
|
||||
_ if new.starts_with('/') => {
|
||||
let pending_command = &new[1..];
|
||||
if self
|
||||
.state
|
||||
.pending_command()
|
||||
.is_some_and(|command| command == pending_command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
// In the old modality, when composing a slash command, the input _must_ be in
|
||||
// AI mode; we don't respect `StaticCommand::auto_enter_ai_mode = false`. That
|
||||
// field is only used in the new modality.
|
||||
//
|
||||
// We don't even rely on the fact that the input is in AI mode while a slash
|
||||
// command is being composed, its solely used to disable error underlining.
|
||||
//
|
||||
// In the new modality, slash commands declare whether or not they are
|
||||
// available in terminal mode, and syntax highlighting/error underlining is
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
if pending_command
|
||||
.split_once(' ')
|
||||
.map_or(pending_command, |(command, _)| command)
|
||||
.contains('/')
|
||||
{
|
||||
// If the user typed a second '/' in the command token (e.g., /foo/bar),
|
||||
// the user is likely not trying to enter or find a slash command.
|
||||
self.state = SlashCommandEntryState::None;
|
||||
} else {
|
||||
self.state = SlashCommandEntryState::Composing {
|
||||
filter: pending_command.to_owned(),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.state = SlashCommandEntryState::None;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit(UpdatedSlashCommandModel { old_state });
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SlashCommandModel {
|
||||
type Event = UpdatedSlashCommandModel;
|
||||
}
|
||||
|
||||
impl SlashCommandDataSource {
|
||||
// Matches `buffer` against active slash commands, returning the detected command and
|
||||
// space-delimited argument (if provided).
|
||||
//
|
||||
// If a slash command has no argument, it matches only if its an exact match or the
|
||||
// suffix is all whitespace.
|
||||
//
|
||||
// If the slash command has an argument, it matches only if its an exact match, or if the argument
|
||||
// is space-delimited.
|
||||
fn parse_slash_command(&self, buffer: &str) -> Option<DetectedCommand> {
|
||||
let (possible_command, possible_argument) =
|
||||
if let Some((command, argument)) = buffer.split_once(" ") {
|
||||
(command, Some(argument.to_owned()))
|
||||
} else {
|
||||
(buffer, None)
|
||||
};
|
||||
|
||||
let is_matching_command = |command: &StaticCommand| -> bool {
|
||||
if command.name != possible_command {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(argument) = command.argument.as_ref() {
|
||||
argument.is_optional || possible_argument.as_ref().is_some()
|
||||
} else {
|
||||
possible_argument
|
||||
.as_ref()
|
||||
.is_none_or(|arg| arg.trim().is_empty())
|
||||
}
|
||||
};
|
||||
let matched_command = self.active_commands().find_map(|(_, command)| {
|
||||
if is_matching_command(command) {
|
||||
Some(command.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
|
||||
Some(DetectedCommand {
|
||||
command: matched_command,
|
||||
argument: possible_argument,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "slash_command_model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,498 @@
|
||||
use super::SlashCommandEntryState;
|
||||
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 settings::Setting as _;
|
||||
use warpui::{App, SingletonEntity as _};
|
||||
|
||||
#[test]
|
||||
fn test_parse_slash_command_handles_argument_rules() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
let slash_command_data_source =
|
||||
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
||||
|
||||
slash_command_data_source.read(&app, |data_source, _| {
|
||||
let no_argument_command_name = data_source
|
||||
.active_commands()
|
||||
.find_map(|(_, command)| command.argument.is_none().then_some(command.name))
|
||||
.expect("expected at least one slash command without an argument");
|
||||
|
||||
let optional_argument_command_name = data_source
|
||||
.active_commands()
|
||||
.find_map(|(_, command)| {
|
||||
(command.argument.is_some()
|
||||
&& data_source.parse_slash_command(command.name).is_some())
|
||||
.then_some(command.name)
|
||||
})
|
||||
.expect("expected at least one slash command that accepts optional arguments");
|
||||
|
||||
let with_extra_text = format!("{no_argument_command_name} trailing");
|
||||
assert!(
|
||||
data_source.parse_slash_command(&with_extra_text).is_none(),
|
||||
"commands without arguments should reject non-whitespace suffixes"
|
||||
);
|
||||
|
||||
let with_whitespace = format!("{no_argument_command_name} ");
|
||||
let detected_with_whitespace = data_source
|
||||
.parse_slash_command(&with_whitespace)
|
||||
.expect("command with whitespace-only suffix should still be parsed");
|
||||
assert_eq!(
|
||||
detected_with_whitespace.command.name,
|
||||
no_argument_command_name
|
||||
);
|
||||
|
||||
let detected_without_argument = data_source
|
||||
.parse_slash_command(optional_argument_command_name)
|
||||
.expect("optional-argument command should parse without an argument");
|
||||
assert_eq!(detected_without_argument.argument, None);
|
||||
|
||||
let with_argument = format!("{optional_argument_command_name} prompt");
|
||||
let detected_with_argument = data_source
|
||||
.parse_slash_command(&with_argument)
|
||||
.expect("optional-argument command should parse with an argument");
|
||||
assert_eq!(detected_with_argument.argument.as_deref(), Some("prompt"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rename_tab_slash_command_arguments() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
let slash_command_data_source =
|
||||
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
||||
|
||||
slash_command_data_source.read(&app, |data_source, _| {
|
||||
assert!(
|
||||
data_source
|
||||
.parse_slash_command(commands::RENAME_TAB.name)
|
||||
.is_none(),
|
||||
"expected /rename-tab without an argument not to parse"
|
||||
);
|
||||
|
||||
let detected_with_argument = data_source
|
||||
.parse_slash_command("/rename-tab Backend")
|
||||
.expect("expected /rename-tab to parse with an argument");
|
||||
assert_eq!(detected_with_argument.argument.as_deref(), Some("Backend"));
|
||||
|
||||
let detected_with_multi_word_argument = data_source
|
||||
.parse_slash_command("/rename-tab Backend API")
|
||||
.expect("expected /rename-tab to preserve the rest of the line as one argument");
|
||||
assert_eq!(
|
||||
detected_with_multi_word_argument.argument.as_deref(),
|
||||
Some("Backend API")
|
||||
);
|
||||
|
||||
let detected_with_empty_argument = data_source
|
||||
.parse_slash_command("/rename-tab ")
|
||||
.expect("expected /rename-tab to parse once the required argument is started");
|
||||
assert_eq!(detected_with_empty_argument.argument.as_deref(), Some(""));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_ai_commands_remain_active_when_ai_is_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
let slash_command_data_source =
|
||||
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
||||
|
||||
// Disable AI globally.
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
report_if_error!(settings.is_any_ai_enabled.set_value(false, ctx));
|
||||
});
|
||||
|
||||
slash_command_data_source.read(&app, |data_source, _| {
|
||||
let active_command_names: Vec<&str> = data_source
|
||||
.active_commands()
|
||||
.map(|(_, command)| command.name)
|
||||
.collect();
|
||||
|
||||
// Commands that don't require AI should still be active.
|
||||
// `/rename-tab` is a good canary because it has no session-context requirements
|
||||
// other than ALWAYS.
|
||||
assert!(
|
||||
active_command_names.contains(&commands::RENAME_TAB.name),
|
||||
"/rename-tab should remain active when AI is off, got: {active_command_names:?}"
|
||||
);
|
||||
|
||||
// Commands that require AI should be filtered out.
|
||||
assert!(
|
||||
!active_command_names.contains(&commands::AGENT.name),
|
||||
"/agent should NOT be active when AI is off, got: {active_command_names:?}"
|
||||
);
|
||||
assert!(
|
||||
!active_command_names.contains(&commands::PLAN.name),
|
||||
"/plan should NOT be active when AI is off, got: {active_command_names:?}"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_until_empty_buffer_ignores_non_slash_edits() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
input.user_insert("echo hello", ctx);
|
||||
});
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.slash_command_model.update(ctx, |model, ctx| {
|
||||
model.disable(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
assert!(matches!(
|
||||
input.slash_command_model.as_ref(ctx).state(),
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer
|
||||
));
|
||||
});
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.user_insert(" world", ctx);
|
||||
});
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
assert!(matches!(
|
||||
input.slash_command_model.as_ref(ctx).state(),
|
||||
SlashCommandEntryState::DisabledUntilEmptyBuffer
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_until_empty_buffer_reevaluates_when_slash_is_added_to_start() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
let slash_command_data_source =
|
||||
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
||||
let detected_command_name = slash_command_data_source.read(&app, |data_source, _| {
|
||||
data_source
|
||||
.active_commands()
|
||||
.find_map(|(_, command)| {
|
||||
data_source
|
||||
.parse_slash_command(command.name)
|
||||
.is_some()
|
||||
.then(|| command.name.to_owned())
|
||||
})
|
||||
.expect("expected at least one active slash command")
|
||||
});
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
input.user_insert("echo hello", ctx);
|
||||
});
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.slash_command_model.update(ctx, |model, ctx| {
|
||||
model.disable(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.user_replace_editor_text(&detected_command_name, ctx);
|
||||
});
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
assert!(
|
||||
matches!(
|
||||
input.slash_command_model.as_ref(ctx).state(),
|
||||
SlashCommandEntryState::SlashCommand(_)
|
||||
),
|
||||
"adding '/' at the start of the buffer should re-enable slash command parsing"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_second_slash_in_command_token_sets_state_to_none() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(
|
||||
&mut app, None, /* history_file_commands */
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
input.user_insert("/foo/bar", ctx);
|
||||
});
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
assert!(matches!(
|
||||
input.slash_command_model.as_ref(ctx).state(),
|
||||
SlashCommandEntryState::None
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_returns_none_for_plain_text() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
let result = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command("fix the tests", ctx);
|
||||
assert!(
|
||||
matches!(result, SlashCommandEntryState::None),
|
||||
"plain text should not be detected as a command"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_finds_known_slash_command() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
let slash_command_data_source = &input.slash_command_data_source;
|
||||
let command_name = slash_command_data_source
|
||||
.as_ref(ctx)
|
||||
.active_commands()
|
||||
.find_map(|(_, command)| {
|
||||
slash_command_data_source
|
||||
.as_ref(ctx)
|
||||
.parse_slash_command(command.name)
|
||||
.is_some()
|
||||
.then(|| command.name.to_owned())
|
||||
})
|
||||
.expect("expected at least one active slash command");
|
||||
|
||||
let result = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command(&command_name, ctx);
|
||||
assert!(
|
||||
matches!(result, SlashCommandEntryState::SlashCommand(_)),
|
||||
"known command should be detected: {command_name}"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_returns_none_for_unknown_slash() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
let result = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command("/nonexistent-command-xyz", ctx);
|
||||
assert!(
|
||||
matches!(result, SlashCommandEntryState::None),
|
||||
"unknown /command should return None"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_matches_buffer_driven_detection() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
let command_name = input.read(&app, |input, ctx| {
|
||||
let ds = &input.slash_command_data_source;
|
||||
ds.as_ref(ctx)
|
||||
.active_commands()
|
||||
.find_map(|(_, command)| {
|
||||
ds.as_ref(ctx)
|
||||
.parse_slash_command(command.name)
|
||||
.is_some()
|
||||
.then(|| command.name.to_owned())
|
||||
})
|
||||
.expect("expected at least one active slash command")
|
||||
});
|
||||
|
||||
// Type the command into the buffer so the buffer-driven model processes it.
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
input.user_insert(&command_name, ctx);
|
||||
});
|
||||
|
||||
// Compare the buffer-driven state with the stateless detect_command result.
|
||||
input.read(&app, |input, ctx| {
|
||||
let buffer_state = input.slash_command_model.as_ref(ctx).state().clone();
|
||||
let detect_state = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command(&command_name, ctx);
|
||||
|
||||
let buffer_is_slash = matches!(buffer_state, SlashCommandEntryState::SlashCommand(_));
|
||||
let detect_is_slash = matches!(detect_state, SlashCommandEntryState::SlashCommand(_));
|
||||
assert_eq!(
|
||||
buffer_is_slash, detect_is_slash,
|
||||
"detect_command and buffer-driven detection should agree for '{command_name}'"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_parses_queue_with_argument() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
let result = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command("/queue fix the tests", ctx);
|
||||
|
||||
if let SlashCommandEntryState::SlashCommand(detected) = result {
|
||||
assert_eq!(detected.command.name, "/queue");
|
||||
assert_eq!(detected.argument.as_deref(), Some("fix the tests"));
|
||||
} else {
|
||||
// /queue may not be registered if the feature flag is off in tests.
|
||||
// That's fine — this test validates argument extraction when it is.
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_command_returns_queue_with_no_argument() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.read(&app, |input, ctx| {
|
||||
let result = input
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.detect_command("/queue", ctx);
|
||||
|
||||
// /queue requires an argument, so bare "/queue" should not be detected.
|
||||
// (StaticCommand with required argument rejects input without a space-delimited arg.)
|
||||
assert!(
|
||||
!matches!(result, SlashCommandEntryState::SlashCommand(_)),
|
||||
"/queue with no argument should not be detected as a complete slash command"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_queued_prompt_routes_plain_text_to_conversation() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
// Set up AI input mode so the input can interact with the AI controller.
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
});
|
||||
|
||||
// submit_queued_prompt with plain text should not panic or crash.
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_queued_prompt_detects_slash_command() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
||||
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
||||
|
||||
input.update(&mut app, |input, ctx| {
|
||||
input.set_input_mode_natural_language_detection(ctx);
|
||||
});
|
||||
|
||||
// Verify that submit_queued_prompt correctly detects slash commands.
|
||||
// Find a command that exists and has an optional argument.
|
||||
let command_with_arg = input.read(&app, |input, ctx| {
|
||||
let ds = &input.slash_command_data_source;
|
||||
ds.as_ref(ctx).active_commands().find_map(|(_, command)| {
|
||||
(command.argument.as_ref().is_some_and(|a| a.is_optional)
|
||||
&& ds.as_ref(ctx).parse_slash_command(command.name).is_some())
|
||||
.then(|| command.name.to_owned())
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(command_text) = command_with_arg {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
mod saved_prompts;
|
||||
mod zero_state;
|
||||
|
||||
use ai::skills::SkillProvider;
|
||||
pub(crate) use saved_prompts::*;
|
||||
use warp_core::features::FeatureFlag;
|
||||
pub use zero_state::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::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::Availability;
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
||||
};
|
||||
use crate::terminal::model::session::SessionType;
|
||||
use warp_core::ui::Icon as WarpIcon;
|
||||
|
||||
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},
|
||||
};
|
||||
|
||||
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 struct SlashCommandDataSource {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
cli_subagent_controller: ModelHandle<CLISubagentController>,
|
||||
terminal_view_id: EntityId,
|
||||
active_commands_by_id: HashMap<SlashCommandId, StaticCommand>,
|
||||
active_repo_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl SlashCommandDataSource {
|
||||
pub fn new(args: DataSourceArgs, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let DataSourceArgs {
|
||||
active_session,
|
||||
agent_view_controller,
|
||||
cli_subagent_controller,
|
||||
terminal_view_id,
|
||||
} = args;
|
||||
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| {
|
||||
if let CLISubagentEvent::SpawnedSubagent { .. }
|
||||
| CLISubagentEvent::FinishedSubagent { .. }
|
||||
| CLISubagentEvent::UpdatedControl { .. } = event
|
||||
{
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
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 { .. }
|
||||
| AISettingsChangedEvent::OrchestrationEnabled { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&InputSettings::handle(ctx), |me, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
InputSettingsChangedEvent::EnableSlashCommandsInTerminal { .. }
|
||||
) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| {
|
||||
if matches!(event, UserWorkspacesEvent::CodebaseContextEnablementChanged) {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(
|
||||
&CLIAgentSessionsModel::handle(ctx),
|
||||
move |me, event, ctx| {
|
||||
if let CLIAgentSessionsModelEvent::InputSessionChanged {
|
||||
terminal_view_id: event_terminal_view_id,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if *event_terminal_view_id == terminal_view_id {
|
||||
me.recompute_active_commands(ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut me = Self {
|
||||
active_session,
|
||||
agent_view_controller,
|
||||
cli_subagent_controller,
|
||||
terminal_view_id,
|
||||
active_commands_by_id: Default::default(),
|
||||
active_repo_root: None,
|
||||
};
|
||||
me.recompute_active_commands(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
/// Slash commands that are available in CLI agent rich input mode.
|
||||
/// Add command names here to make them accessible when composing prompts
|
||||
/// for a running CLI agent (Claude Code, Codex, etc.).
|
||||
const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills"];
|
||||
|
||||
fn recompute_active_commands(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let is_cli_agent_input = self.is_cli_agent_input_open(ctx);
|
||||
|
||||
let mut session_context = Availability::empty();
|
||||
|
||||
let is_agent_view_active = self.agent_view_controller.as_ref(ctx).is_active();
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
// When the AgentView feature flag is disabled, set both view bits so that
|
||||
// either view requirement is satisfied (but other requirements like
|
||||
// REPOSITORY and LOCAL still apply).
|
||||
session_context |= Availability::AGENT_VIEW | Availability::TERMINAL_VIEW;
|
||||
} else if is_agent_view_active {
|
||||
session_context |= Availability::AGENT_VIEW;
|
||||
} else {
|
||||
session_context |= Availability::TERMINAL_VIEW;
|
||||
}
|
||||
|
||||
if self.active_repo_root.is_some() {
|
||||
session_context |= Availability::REPOSITORY;
|
||||
}
|
||||
|
||||
let is_local = self
|
||||
.active_session
|
||||
.as_ref(ctx)
|
||||
.session_type(ctx)
|
||||
.is_some_and(|st| st == SessionType::Local);
|
||||
if is_local {
|
||||
session_context |= Availability::LOCAL;
|
||||
}
|
||||
|
||||
if !self
|
||||
.cli_subagent_controller
|
||||
.as_ref(ctx)
|
||||
.is_agent_in_control()
|
||||
{
|
||||
session_context |= Availability::NO_LRC_CONTROL;
|
||||
}
|
||||
|
||||
let has_active_conversation = if is_agent_view_active {
|
||||
// There is always an active conversation in the agent view.
|
||||
true
|
||||
} else {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id)
|
||||
.is_some()
|
||||
};
|
||||
if has_active_conversation {
|
||||
session_context |= Availability::ACTIVE_CONVERSATION;
|
||||
}
|
||||
|
||||
if UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx) {
|
||||
session_context |= Availability::CODEBASE_CONTEXT;
|
||||
}
|
||||
|
||||
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the active repository root for this terminal. Called by the parent when
|
||||
/// the terminal navigates into or out of a git repository.
|
||||
pub fn set_active_repo_root(
|
||||
&mut self,
|
||||
repo_root: Option<PathBuf>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.active_repo_root != repo_root {
|
||||
self.active_repo_root = repo_root;
|
||||
self.recompute_active_commands(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_commands(&self) -> impl Iterator<Item = (&SlashCommandId, &StaticCommand)> {
|
||||
self.active_commands_by_id.iter()
|
||||
}
|
||||
|
||||
pub fn is_agent_view_active(&self, ctx: &AppContext) -> bool {
|
||||
self.agent_view_controller.as_ref(ctx).is_active()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Returns the supported skill providers for the active CLI agent, or `None` if
|
||||
/// CLI agent input is not open (meaning no filtering should be applied).
|
||||
pub fn active_cli_agent_providers(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> Option<&'static [ai::skills::SkillProvider]> {
|
||||
CLIAgentSessionsModel::as_ref(ctx)
|
||||
.session(self.terminal_view_id)
|
||||
.filter(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
|
||||
.map(|s| s.agent.supported_skill_providers())
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for SlashCommandDataSource {
|
||||
type Action = AcceptSlashCommandOrSavedPrompt;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &warpui::AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
if query.text.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
/// Multiplier to ensure static commands always appear at the top of the match results.
|
||||
const SCORE_MULTIPLIER: OrderedFloat<f64> = OrderedFloat(1000.0);
|
||||
|
||||
for (id, command) in self.active_commands_by_id.iter() {
|
||||
if let Some(fuzzy_result) = SlashCommandFuzzyMatchResult::try_match(
|
||||
&query_text,
|
||||
command.name,
|
||||
None, // Don't match on description for slash commands.
|
||||
) {
|
||||
let score = fuzzy_result.score();
|
||||
|
||||
// Only include results with score > 25 once the user has started typing a query and is past the first character
|
||||
if query_text.len() > 1 && score <= 25.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boost prefix matches so that closer matches (e.g. "new" → "/new")
|
||||
// rank above longer fuzzy matches (e.g. "new" → "/create-new-project").
|
||||
let prefix_boost = prefix_match_bonus(&query_text, command.name);
|
||||
|
||||
results.push(QueryResult::from(
|
||||
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_score(
|
||||
OrderedFloat(score) * SCORE_MULTIPLIER
|
||||
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
|
||||
// Boost commands with shorter names, if match result is otherwise
|
||||
// equal.
|
||||
+ OrderedFloat(1. / command.name.len() as f64),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Also search skills — when CLI agent input is open, filter to natively supported providers.
|
||||
// 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 skills = SkillManager::handle(app)
|
||||
.as_ref(app)
|
||||
.get_skills_for_working_directory(cwd_path, app);
|
||||
|
||||
let skill_manager = SkillManager::as_ref(app);
|
||||
for mut skill in skills {
|
||||
// In CLI agent input mode, only show skills that exist in a supported
|
||||
// provider folder. We check all paths (not just the deduplicated
|
||||
// provider) because deduplication may have picked a higher-priority
|
||||
// provider even when the skill also exists in the CLI agent's folder.
|
||||
if let Some(providers) = &cli_agent_providers {
|
||||
if !skill_manager.skill_exists_for_any_provider(&skill, providers) {
|
||||
continue;
|
||||
}
|
||||
// Re-map the provider to the best supported one so the icon
|
||||
// reflects the active CLI agent's native provider.
|
||||
skill.provider = skill_manager.best_supported_provider(&skill, providers);
|
||||
}
|
||||
if let Some(fuzzy_result) = SlashCommandFuzzyMatchResult::try_match(
|
||||
&query_text,
|
||||
&skill.name,
|
||||
Some(&skill.description),
|
||||
) {
|
||||
let score = fuzzy_result.score();
|
||||
|
||||
// Only include results with score > 25 once the user has started typing a query
|
||||
if query_text.len() > 1 && score <= 25.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prefix_boost = prefix_match_bonus(&query_text, &skill.name);
|
||||
|
||||
results.push(QueryResult::from(
|
||||
InlineItem::from_skill(&skill, app)
|
||||
.with_name_match_result(fuzzy_result.name_match_result)
|
||||
.with_description_match_result(fuzzy_result.description_match_result)
|
||||
.with_score(
|
||||
OrderedFloat(score) * SCORE_MULTIPLIER
|
||||
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
|
||||
+ OrderedFloat(1. / skill.name.len() as f64),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a bonus score for slash command matches where the query is a prefix
|
||||
/// of the command name. This ensures closer matches (e.g., "new" → "/new") rank
|
||||
/// above longer fuzzy matches (e.g., "new" → "/figma-create-new-file").
|
||||
///
|
||||
/// Returns a value in `[0.0, 100.0]` based on the query's coverage of the name.
|
||||
/// An exact match yields the maximum bonus of 100; partial prefix matches yield
|
||||
/// a proportionally smaller bonus.
|
||||
fn prefix_match_bonus(query: &str, name: &str) -> f64 {
|
||||
let name_lower = name.to_lowercase();
|
||||
let name_stripped = name_lower.strip_prefix('/').unwrap_or(&name_lower);
|
||||
if name_stripped.starts_with(query) {
|
||||
// coverage = 1.0 for exact match, smaller for partial prefix match.
|
||||
let coverage = query.len() as f64 / name_stripped.len() as f64;
|
||||
coverage * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct UpdatedActiveCommands;
|
||||
|
||||
impl Entity for SlashCommandDataSource {
|
||||
type Event = UpdatedActiveCommands;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InlineItem {
|
||||
pub action: AcceptSlashCommandOrSavedPrompt,
|
||||
pub icon_path: &'static str,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub font_family: FamilyId,
|
||||
pub name_match_result: Option<FuzzyMatchResult>,
|
||||
pub description_match_result: Option<FuzzyMatchResult>,
|
||||
pub score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl InlineItem {
|
||||
fn from_slash_command(
|
||||
command_id: &SlashCommandId,
|
||||
command: &StaticCommand,
|
||||
app: &AppContext,
|
||||
) -> Self {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Self {
|
||||
action: AcceptSlashCommandOrSavedPrompt::SlashCommand { id: *command_id },
|
||||
icon_path: command.icon_path,
|
||||
name: command.name.to_owned(),
|
||||
description: Some(command.description.to_owned()),
|
||||
font_family: appearance.monospace_font_family(),
|
||||
name_match_result: None,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_skill(skill: &SkillDescriptor, app: &AppContext) -> Self {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
// Use icon_override if set (e.g. Figma skills), otherwise derive from provider.
|
||||
let icon = if let Some(override_icon) = skill.icon_override {
|
||||
override_icon
|
||||
} else {
|
||||
match skill.provider {
|
||||
SkillProvider::Warp => WarpIcon::Warp,
|
||||
SkillProvider::Claude => WarpIcon::ClaudeLogo,
|
||||
SkillProvider::Codex => WarpIcon::OpenAILogo,
|
||||
SkillProvider::Gemini => WarpIcon::GeminiLogo,
|
||||
SkillProvider::Droid => WarpIcon::DroidLogo,
|
||||
SkillProvider::OpenCode => WarpIcon::OpenCodeLogo,
|
||||
_ => WarpIcon::Warp,
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
action: AcceptSlashCommandOrSavedPrompt::Skill {
|
||||
reference: skill.reference.clone(),
|
||||
name: skill.name.clone(),
|
||||
},
|
||||
icon_path: icon.into(),
|
||||
name: format!("/{}", &skill.name),
|
||||
description: Some(skill.description.clone()),
|
||||
font_family: appearance.monospace_font_family(),
|
||||
name_match_result: None,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_description_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.description_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::search::slash_command_menu::fuzzy_match::SlashCommandFuzzyMatchResult;
|
||||
|
||||
use super::prefix_match_bonus;
|
||||
|
||||
#[test]
|
||||
fn exact_match_returns_full_bonus() {
|
||||
// Query "new" exactly matches the name "/new" (after stripping '/').
|
||||
let bonus = prefix_match_bonus("new", "/new");
|
||||
assert!((bonus - 100.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_prefix_returns_proportional_bonus() {
|
||||
// "for" is a prefix of "fork" → coverage 3/4 = 75.
|
||||
let bonus = prefix_match_bonus("for", "/fork");
|
||||
assert!((bonus - 75.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_prefix_returns_zero() {
|
||||
// "new" is NOT a prefix of "create-new-project".
|
||||
let bonus = prefix_match_bonus("new", "/create-new-project");
|
||||
assert!((bonus - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
let bonus = prefix_match_bonus("new", "/New");
|
||||
assert!((bonus - 100.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_without_slash_prefix() {
|
||||
// Skills don't have the '/' prefix in their name.
|
||||
let bonus = prefix_match_bonus("figma", "figma-create-new-file");
|
||||
let coverage = 5.0 / 21.0 * 100.0;
|
||||
assert!((bonus - coverage).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_prefix_match_ranks_above_longer_fuzzy_match() {
|
||||
// Simulates the reported issue: query "new" should give /new a much
|
||||
// higher combined score than /figma-create-new-file.
|
||||
let short_match = SlashCommandFuzzyMatchResult::try_match("new", "/new", None).unwrap();
|
||||
let long_match =
|
||||
SlashCommandFuzzyMatchResult::try_match("new", "/figma-create-new-file", None).unwrap();
|
||||
|
||||
const SCORE_MULTIPLIER: f64 = 1000.0;
|
||||
|
||||
let short_score = short_match.score() * SCORE_MULTIPLIER
|
||||
+ prefix_match_bonus("new", "/new") * SCORE_MULTIPLIER
|
||||
+ 1.0 / "/new".len() as f64;
|
||||
let long_score = long_match.score() * SCORE_MULTIPLIER
|
||||
+ prefix_match_bonus("new", "/figma-create-new-file") * SCORE_MULTIPLIER
|
||||
+ 1.0 / "/figma-create-new-file".len() as f64;
|
||||
|
||||
assert!(
|
||||
short_score > long_score,
|
||||
"/new score ({short_score}) should be greater than /figma-create-new-file score ({long_score})"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_lite::future::yield_now;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::search::async_snapshot_data_source::AsyncSnapshotDataSource;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::{BoxFuture, DataSourceRunErrorWrapper};
|
||||
use crate::search::FuzzyMatchWorkflowResult;
|
||||
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>,
|
||||
pub(super) breadcrumbs: String,
|
||||
}
|
||||
|
||||
pub(crate) struct SavedPromptsSnapshot {
|
||||
candidates: Vec<SavedPromptCandidate>,
|
||||
query_text: String,
|
||||
font_family: FamilyId,
|
||||
/// Saved prompts are Agent Mode workflows destined for the AI agent, so they shouldn't be
|
||||
/// surfaced when AI is globally disabled. Captured at snapshot time so the async match step
|
||||
/// can bail without touching app state.
|
||||
ai_enabled: bool,
|
||||
}
|
||||
|
||||
/// Creates an async data source for saved prompts (Agent Mode workflows) in the slash command
|
||||
/// menu.
|
||||
///
|
||||
/// We use fuzzy search rather than Tantivy full-text search. Switching to Tantivy would avoid
|
||||
/// cloning all workflow data on each query, but would require exposing the Tantivy reader for
|
||||
/// off-thread use.
|
||||
pub(crate) fn saved_prompts_data_source(
|
||||
) -> AsyncSnapshotDataSource<SavedPromptsSnapshot, AcceptSlashCommandOrSavedPrompt> {
|
||||
AsyncSnapshotDataSource::new(
|
||||
|query: &Query, app: &AppContext| {
|
||||
let ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
|
||||
// Skip the workflow scan entirely when AI is off; the match step will return empty.
|
||||
let candidates: Vec<SavedPromptCandidate> = if ai_enabled {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_active_workflows()
|
||||
.filter(|cw| cw.model().data.is_agent_mode_workflow())
|
||||
.map(|cw| SavedPromptCandidate {
|
||||
id: cw.id,
|
||||
model: cw.shared_model(),
|
||||
breadcrumbs: cw.breadcrumbs(app),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
SavedPromptsSnapshot {
|
||||
candidates,
|
||||
query_text: query.text.trim().to_owned(),
|
||||
font_family: Appearance::as_ref(app).ui_font_family(),
|
||||
ai_enabled,
|
||||
}
|
||||
},
|
||||
fuzzy_match_saved_prompts,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fuzzy_match_saved_prompts(
|
||||
snapshot: SavedPromptsSnapshot,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<Vec<QueryResult<AcceptSlashCommandOrSavedPrompt>>, DataSourceRunErrorWrapper>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
if !snapshot.ai_enabled || snapshot.query_text.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// For single-character queries, use prefix matching on the name instead of
|
||||
// fuzzy search to avoid missing valid results.
|
||||
let prefix_char = (snapshot.query_text.chars().count() == 1)
|
||||
.then(|| snapshot.query_text.chars().next().unwrap());
|
||||
let mut results = Vec::new();
|
||||
|
||||
for chunk in snapshot.candidates.chunks(128) {
|
||||
for candidate in chunk {
|
||||
if let Some(query_char) = prefix_char {
|
||||
if candidate
|
||||
.model
|
||||
.data
|
||||
.name_starts_with_char_ignore_case(query_char)
|
||||
{
|
||||
let name_match_result = Some(fuzzy_match::FuzzyMatchResult {
|
||||
score: 100,
|
||||
matched_indices: vec![0],
|
||||
});
|
||||
// All prefix matches share the same score; ordering among them
|
||||
// is determined by the candidate insertion order, which is fine
|
||||
// for single-character queries.
|
||||
let item = InlineItem {
|
||||
action: AcceptSlashCommandOrSavedPrompt::SavedPrompt {
|
||||
id: candidate.id,
|
||||
},
|
||||
icon_path: "bundled/svg/prompt.svg",
|
||||
name: candidate.model.data.name().to_owned(),
|
||||
description: None,
|
||||
font_family: snapshot.font_family,
|
||||
name_match_result,
|
||||
description_match_result: None,
|
||||
score: OrderedFloat(100.0),
|
||||
};
|
||||
results.push(QueryResult::from(item));
|
||||
}
|
||||
} else {
|
||||
let match_result = FuzzyMatchWorkflowResult::try_match(
|
||||
&snapshot.query_text,
|
||||
&candidate.model.data,
|
||||
&candidate.breadcrumbs,
|
||||
);
|
||||
|
||||
if let Some(match_result) = match_result {
|
||||
let score = match_result.score();
|
||||
|
||||
// Avoid spamming results with extremely weak matches.
|
||||
if score <= OrderedFloat(25.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = InlineItem {
|
||||
action: AcceptSlashCommandOrSavedPrompt::SavedPrompt {
|
||||
id: candidate.id,
|
||||
},
|
||||
icon_path: "bundled/svg/prompt.svg",
|
||||
name: candidate.model.data.name().to_owned(),
|
||||
description: None,
|
||||
font_family: snapshot.font_family,
|
||||
name_match_result: match_result.name_match_result,
|
||||
description_match_result: match_result.content_match_result,
|
||||
score,
|
||||
};
|
||||
results.push(QueryResult::from(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
yield_now().await;
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "saved_prompts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,136 @@
|
||||
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};
|
||||
|
||||
const TEST_FONT_FAMILY: warpui::fonts::FamilyId = warpui::fonts::FamilyId(0);
|
||||
|
||||
fn test_candidate(name: &str, query: &str) -> SavedPromptCandidate {
|
||||
SavedPromptCandidate {
|
||||
id: SyncId::ClientId(ClientId::new()),
|
||||
model: Arc::new(CloudWorkflowModel {
|
||||
data: Workflow::AgentMode {
|
||||
name: name.to_owned(),
|
||||
query: query.to_owned(),
|
||||
description: None,
|
||||
arguments: vec![],
|
||||
},
|
||||
}),
|
||||
breadcrumbs: "Personal".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_match(
|
||||
query_text: &str,
|
||||
candidates: Vec<SavedPromptCandidate>,
|
||||
) -> Vec<crate::search::data_source::QueryResult<super::AcceptSlashCommandOrSavedPrompt>> {
|
||||
run_match_with_ai(query_text, candidates, true)
|
||||
}
|
||||
|
||||
fn run_match_with_ai(
|
||||
query_text: &str,
|
||||
candidates: Vec<SavedPromptCandidate>,
|
||||
ai_enabled: bool,
|
||||
) -> Vec<crate::search::data_source::QueryResult<super::AcceptSlashCommandOrSavedPrompt>> {
|
||||
futures_lite::future::block_on(fuzzy_match_saved_prompts(SavedPromptsSnapshot {
|
||||
candidates,
|
||||
query_text: query_text.to_owned(),
|
||||
font_family: TEST_FONT_FAMILY,
|
||||
ai_enabled,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_query_returns_no_results() {
|
||||
let candidates = vec![test_candidate("My Prompt", "do something")];
|
||||
let results = run_match("", candidates);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_query_returns_results() {
|
||||
let candidates = vec![test_candidate(
|
||||
"Refactor Code",
|
||||
"refactor the selected code",
|
||||
)];
|
||||
let results = run_match("refactor", candidates);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].score() > OrderedFloat(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_matching_query_returns_no_results() {
|
||||
let candidates = vec![test_candidate(
|
||||
"Refactor Code",
|
||||
"refactor the selected code",
|
||||
)];
|
||||
let results = run_match("zzzzzzzzz", candidates);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weak_matches_filtered_when_query_len_over_one() {
|
||||
// A two-character query that barely matches should be filtered by the score > 25 threshold.
|
||||
let candidates = vec![test_candidate(
|
||||
"A very long prompt name",
|
||||
"some content here",
|
||||
)];
|
||||
let results = run_match("zx", candidates);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_char_query_uses_prefix_match() {
|
||||
// Single-character queries use prefix matching on the name.
|
||||
let candidates = vec![test_candidate("Abc", "do something")];
|
||||
let results = run_match("a", candidates);
|
||||
assert!(!results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_char_query_no_prefix_match_returns_empty() {
|
||||
// Single-character queries that don't prefix-match the name should return no results,
|
||||
// even if the character appears elsewhere in the name or content.
|
||||
let candidates = vec![test_candidate("Refactor Code", "apply changes")];
|
||||
let results = run_match("a", candidates);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_char_query_matches_multiple_by_prefix() {
|
||||
let candidates = vec![
|
||||
test_candidate("Deploy to Production", "deploy app"),
|
||||
test_candidate("Debug Tests", "run debugger"),
|
||||
test_candidate("Run Tests", "cargo test"),
|
||||
];
|
||||
let results = run_match("d", candidates);
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_workflows_returns_matching_subset() {
|
||||
let candidates = vec![
|
||||
test_candidate("Deploy to Production", "deploy app to prod"),
|
||||
test_candidate("Run Tests", "cargo test"),
|
||||
test_candidate("Deploy Staging", "deploy app to staging"),
|
||||
];
|
||||
let results = run_match("deploy", candidates);
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_disabled_returns_no_results_even_for_matching_query() {
|
||||
// Saved prompts are AI-destined, so when AI is globally disabled they should be hidden
|
||||
// regardless of whether the query would otherwise match.
|
||||
let candidates = vec![test_candidate(
|
||||
"Refactor Code",
|
||||
"refactor the selected code",
|
||||
)];
|
||||
let results = run_match_with_ai("refactor", candidates, false);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use itertools::Itertools;
|
||||
use warpui::{Entity, ModelHandle};
|
||||
|
||||
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::terminal::input::slash_commands::{
|
||||
AcceptSlashCommandOrSavedPrompt, InlineItem, SlashCommandDataSource,
|
||||
};
|
||||
|
||||
pub struct ZeroStateDataSource {
|
||||
slash_command_data_source: ModelHandle<SlashCommandDataSource>,
|
||||
}
|
||||
|
||||
impl ZeroStateDataSource {
|
||||
pub fn new(slash_command_data_source: &ModelHandle<SlashCommandDataSource>) -> Self {
|
||||
Self {
|
||||
slash_command_data_source: slash_command_data_source.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ZeroStateDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SyncDataSource for ZeroStateDataSource {
|
||||
type Action = AcceptSlashCommandOrSavedPrompt;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &warpui::AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
if !query.text.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// This is kind of a convoluted way to explicitly order these commands after all others.
|
||||
//
|
||||
// DataSource implementations must return highest priority items last (results sorted in
|
||||
// ascending order of priority).
|
||||
//
|
||||
// The results construction below basically orders all active commands, sorted
|
||||
// alphabetically, except for the commands in this vec, which are explicitly appended
|
||||
// to all the other alphabetically sorted commands, in this order.
|
||||
let prioritized_commands = vec![
|
||||
&*commands::CREATE_ENVIRONMENT,
|
||||
&*commands::EDIT,
|
||||
&commands::CONVERSATIONS,
|
||||
&commands::PROMPTS,
|
||||
&*commands::PLAN,
|
||||
&commands::AGENT,
|
||||
];
|
||||
|
||||
let mut active_prioritized_commands = vec![];
|
||||
let mut results = vec![];
|
||||
|
||||
for (active_command_id, active_command) in self
|
||||
.slash_command_data_source
|
||||
.as_ref(app)
|
||||
.active_commands()
|
||||
.sorted_by_key(|(_, command)| std::cmp::Reverse(&command.name))
|
||||
{
|
||||
if prioritized_commands
|
||||
.iter()
|
||||
.any(|prioritized_command| prioritized_command.name == active_command.name)
|
||||
{
|
||||
active_prioritized_commands.push((active_command_id, active_command));
|
||||
} else {
|
||||
results.push(
|
||||
InlineItem::from_slash_command(active_command_id, active_command, app).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for prioritized_command in prioritized_commands {
|
||||
if let Some((id, command)) = active_prioritized_commands
|
||||
.iter()
|
||||
.find(|(_, active_command)| active_command.name == prioritized_command.name)
|
||||
{
|
||||
results.push(InlineItem::from_slash_command(id, command, app).into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::*;
|
||||
pub use view::*;
|
||||
|
||||
use ai::skills::SkillReference;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::clipboard::ClipboardContent;
|
||||
use warpui::{SingletonEntity, ViewContext};
|
||||
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
AgentViewEntryOrigin, DismissalStrategy, EphemeralMessage, ENTER_OR_EXIT_CONFIRMATION_WINDOW,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, SlashCommandRequest};
|
||||
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};
|
||||
use crate::search::slash_command_menu::static_commands::Availability;
|
||||
use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::telemetry::SlashCommandAcceptedDetails;
|
||||
use crate::settings::AISettings;
|
||||
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::slash_command_model::{
|
||||
SlashCommandEntryState, UpdatedSlashCommandModel,
|
||||
};
|
||||
use crate::terminal::input::{
|
||||
CompletionsTrigger, Event, Input, InputSuggestionsMode, UserQueryMenuAction,
|
||||
};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
||||
use crate::workspace::{ForkedConversationDestination, ToastStack, WorkspaceAction};
|
||||
use crate::TelemetryEvent;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AcceptSlashCommandOrSavedPrompt {
|
||||
SlashCommand {
|
||||
id: SlashCommandId,
|
||||
},
|
||||
SavedPrompt {
|
||||
id: SyncId,
|
||||
},
|
||||
/// A skill selected from browse or search. Contains name (for display/insertion) and path/bundled_skill_id (for execution).
|
||||
Skill {
|
||||
reference: SkillReference,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
impl InlineMenuAction for AcceptSlashCommandOrSavedPrompt {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::SlashCommands;
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum SlashCommandTrigger {
|
||||
Input { cmd_or_ctrl_enter: bool },
|
||||
Keybinding,
|
||||
}
|
||||
|
||||
impl SlashCommandTrigger {
|
||||
fn cmd_or_ctrl_enter() -> Self {
|
||||
Self::Input {
|
||||
cmd_or_ctrl_enter: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input() -> Self {
|
||||
Self::Input {
|
||||
cmd_or_ctrl_enter: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn keybinding() -> Self {
|
||||
Self::Keybinding
|
||||
}
|
||||
|
||||
pub fn is_keybinding(&self) -> bool {
|
||||
matches!(self, Self::Keybinding)
|
||||
}
|
||||
|
||||
fn is_cmd_or_ctrl_enter(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Input {
|
||||
cmd_or_ctrl_enter: true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub(super) fn select_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
trigger: SlashCommandTrigger,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if command.argument.as_ref().is_none() {
|
||||
self.execute_slash_command(
|
||||
command, None, trigger, /*is_queued_prompt*/ false, ctx,
|
||||
);
|
||||
} else if command
|
||||
.argument
|
||||
.as_ref()
|
||||
.is_some_and(|arg| arg.should_execute_on_selection)
|
||||
{
|
||||
// TODO (zachbai): this is a hack for Oz launch. Caller
|
||||
// should probably be invoking `execute_slash_command` in this case.
|
||||
let argument = if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
let trimmed = self.buffer_text(ctx).trim().to_owned();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.execute_slash_command(
|
||||
command,
|
||||
argument.as_ref(),
|
||||
trigger,
|
||||
/*is_queued_prompt*/ false,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text(&format!("{} ", command.name), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_slash_commands_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn handle_slash_command_model_event(
|
||||
&mut self,
|
||||
event: &UpdatedSlashCommandModel,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Refresh decorations if the slash command detection state changed, since
|
||||
// detected commands affect syntax highlighting.
|
||||
let new_state = self.slash_command_model.as_ref(ctx).state();
|
||||
if event.old_state.is_detected_command() != new_state.is_detected_command() {
|
||||
let _ = self
|
||||
.debounce_input_background_tx
|
||||
.try_send(InputBackgroundJobOptions::default().with_command_decoration());
|
||||
}
|
||||
|
||||
match self.slash_command_model.as_ref(ctx).state().clone() {
|
||||
SlashCommandEntryState::None | SlashCommandEntryState::DisabledUntilEmptyBuffer => {
|
||||
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
self.close_slash_commands_menu(ctx);
|
||||
}
|
||||
}
|
||||
SlashCommandEntryState::Composing { .. } => {
|
||||
if self.suggestions_mode_model.as_ref(ctx).is_closed() {
|
||||
self.open_slash_commands_menu(ctx);
|
||||
} else if !self.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
self.slash_command_model.update(ctx, |model, ctx| {
|
||||
model.disable(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
// If there is only one result (or zero, but that should be impossible if there is
|
||||
// a valid command in the input) OR if the user has started typing arguments, hide
|
||||
// the menu.
|
||||
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands()
|
||||
&& (self
|
||||
.inline_slash_commands_view
|
||||
.as_ref(ctx)
|
||||
.result_count(ctx)
|
||||
< 2
|
||||
|| detected_command.argument.is_some())
|
||||
{
|
||||
self.close_slash_commands_menu(ctx);
|
||||
}
|
||||
|
||||
if detected_command.command.auto_enter_ai_mode
|
||||
|| !FeatureFlag::AgentView.is_enabled()
|
||||
{
|
||||
self.enter_ai_mode(ctx);
|
||||
}
|
||||
|
||||
if detected_command.command.name == commands::EDIT.name
|
||||
&& detected_command
|
||||
.argument
|
||||
.as_ref()
|
||||
.is_some_and(|argument| argument.is_empty())
|
||||
&& self.suggestions_mode_model.as_ref(ctx).is_closed()
|
||||
{
|
||||
self.open_completion_suggestions(CompletionsTrigger::Keybinding, ctx);
|
||||
}
|
||||
}
|
||||
SlashCommandEntryState::SkillCommand(detected_skill) => {
|
||||
// Hide the menu once the user has started typing the prompt
|
||||
if self.suggestions_mode_model.as_ref(ctx).is_slash_commands()
|
||||
&& (self
|
||||
.inline_slash_commands_view
|
||||
.as_ref(ctx)
|
||||
.result_count(ctx)
|
||||
< 2
|
||||
|| detected_skill.argument.is_some())
|
||||
{
|
||||
self.close_slash_commands_menu(ctx);
|
||||
}
|
||||
|
||||
// Skill commands always require AI mode
|
||||
self.enter_ai_mode(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_slash_commands_menu_event(
|
||||
&mut self,
|
||||
event: &SlashCommandsEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SlashCommandsEvent::Close(reason) => {
|
||||
if reason.is_manual_dismissal() {
|
||||
self.slash_command_model.update(ctx, |model, ctx| {
|
||||
model.disable(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.suggestions_mode_model.update(ctx, |model, ctx| {
|
||||
model.set_mode(InputSuggestionsMode::Closed, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
SlashCommandsEvent::SelectedSavedPrompt { id } => {
|
||||
let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(id).cloned() else {
|
||||
log::warn!("Tried to execute workflow for id {id:?} but it does not exist");
|
||||
return;
|
||||
};
|
||||
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
|
||||
&& self.agent_view_controller.as_ref(ctx).is_fullscreen();
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SlashCommandAccepted {
|
||||
command_details: SlashCommandAcceptedDetails::SavedPrompt,
|
||||
is_in_agent_view,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.show_workflows_info_box_on_workflow_selection(
|
||||
WorkflowType::Cloud(Box::new(workflow)),
|
||||
WorkflowSource::WarpAI,
|
||||
WorkflowSelectionSource::SlashMenu,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
SlashCommandsEvent::SelectedStaticCommand {
|
||||
id,
|
||||
cmd_or_ctrl_enter,
|
||||
} => {
|
||||
let Some(command) = COMMAND_REGISTRY.get_command(id) else {
|
||||
return;
|
||||
};
|
||||
self.select_slash_command(
|
||||
command,
|
||||
SlashCommandTrigger::Input {
|
||||
cmd_or_ctrl_enter: *cmd_or_ctrl_enter,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
SlashCommandsEvent::SelectedSkill { name, reference: _ } => {
|
||||
// Insert /{skill-name} into the buffer
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text(format!("/{name} ").as_str(), ctx);
|
||||
});
|
||||
self.close_slash_commands_menu(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the given `command` with `argument`, if any.
|
||||
///
|
||||
/// When `is_queued_prompt` is true, this is the first send of a previously queued prompt:
|
||||
/// the input buffer is left alone so the user doesn't lose anything they've typed while
|
||||
/// the agent was busy.
|
||||
///
|
||||
/// Returns `true` if execution was 'handled' (whether or not it resulted in success or failure).
|
||||
pub(super) fn execute_slash_command(
|
||||
&mut self,
|
||||
command: &StaticCommand,
|
||||
argument: Option<&String>,
|
||||
trigger: SlashCommandTrigger,
|
||||
is_queued_prompt: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
fn show_error_toast(message: String, ctx: &mut ViewContext<Input>) {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(DismissibleToast::error(message), window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Safety net: commands whose availability requires AI should not execute when AI is
|
||||
// globally disabled. They're normally filtered out of the slash command menu, but this
|
||||
// protects keybinding-triggered execution where a bound key may still address the command.
|
||||
if command.availability.contains(Availability::AI_ENABLED)
|
||||
&& !AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
|
||||
{
|
||||
show_error_toast(format!("{} requires AI to be enabled", command.name), ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle the slash command action based on its kind
|
||||
match command.name {
|
||||
add_mcp if command.name == commands::ADD_MCP.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenAddMCPPane);
|
||||
}
|
||||
add_prompt if command.name == commands::ADD_PROMPT.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenAddPromptPane);
|
||||
}
|
||||
add_rule if command.name == commands::ADD_RULE.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenAddRulePane);
|
||||
}
|
||||
agent_or_new
|
||||
if command.name == commands::NEW.name || command.name == commands::AGENT.name =>
|
||||
{
|
||||
if !self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.can_start_new_conversation()
|
||||
{
|
||||
self.ephemeral_message_model.update(ctx, |model, ctx| {
|
||||
let appearance = Appearance::handle(ctx).as_ref(ctx);
|
||||
let message = Message::from_text(
|
||||
"cannot start new conversation while terminal command is running",
|
||||
)
|
||||
.with_text_color(appearance.theme().ansi_fg_red());
|
||||
model.show_ephemeral_message(
|
||||
EphemeralMessage::new(
|
||||
message,
|
||||
DismissalStrategy::Timer(ENTER_OR_EXIT_CONFIRMATION_WINDOW),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Keybindings can be triggered reflexively while users are already in an active
|
||||
// conversation, so we gate only this path behind a second-press confirmation.
|
||||
// Typed `/agent`/`/new` and slash-menu execution stay single-step by design.
|
||||
if trigger.is_keybinding() && self.agent_view_controller.as_ref(ctx).is_active() {
|
||||
let should_start_new_conversation =
|
||||
self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller
|
||||
.should_start_new_conversation_for_keybinding(command.name, ctx)
|
||||
});
|
||||
if !should_start_new_conversation {
|
||||
// Keep the current input/conversation untouched on first press; only the
|
||||
// ephemeral confirmation prompt should change.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let prompt = argument.and_then(|argument| {
|
||||
let trimmed = argument.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
});
|
||||
|
||||
ctx.emit(Event::EnterAgentView {
|
||||
initial_prompt: prompt,
|
||||
conversation_id: None,
|
||||
origin: AgentViewEntryOrigin::SlashCommand { trigger },
|
||||
});
|
||||
}
|
||||
cloud_agent if command.name == commands::CLOUD_AGENT.name => {
|
||||
let prompt = argument.and_then(|argument| {
|
||||
let trimmed = argument.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
});
|
||||
|
||||
ctx.emit(Event::EnterCloudAgentView {
|
||||
initial_prompt: prompt,
|
||||
});
|
||||
}
|
||||
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() {
|
||||
self.open_conversation_menu(ctx);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenConversationsPalette);
|
||||
}
|
||||
}
|
||||
rename_tab if command.name == commands::RENAME_TAB.name => {
|
||||
let Some(name) = argument
|
||||
.map(|name| name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
else {
|
||||
show_error_toast(
|
||||
"Please provide a tab name after /rename-tab".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned()));
|
||||
}
|
||||
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| {
|
||||
arg.split_whitespace()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
ctx.emit(Event::TriggerEnvironmentSetup { repos });
|
||||
}
|
||||
create_project if command.name == commands::CREATE_NEW_PROJECT.name => {
|
||||
if argument.is_none_or(|args| args.is_empty()) {
|
||||
show_error_toast(
|
||||
"Please describe the project you want to create after /create-new-project"
|
||||
.to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let args = argument.expect("args are Some()");
|
||||
self.initiate_create_new_project(args.to_owned(), ctx);
|
||||
}
|
||||
edit if command.name == commands::EDIT.name => {
|
||||
#[cfg(feature = "local_fs")]
|
||||
match argument {
|
||||
Some(args) if !args.is_empty() => {
|
||||
use shellexpand::tilde;
|
||||
use warp_util::path::CleanPathResult;
|
||||
|
||||
let Some(session_id) = self.active_block_session_id() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(session) = self.sessions.as_ref(ctx).get(session_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !session.is_local() {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(
|
||||
"The /open-file command is only available for local sessions"
|
||||
.to_owned(),
|
||||
),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_dir = self
|
||||
.active_block_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.current_working_directory())
|
||||
.map(std::path::PathBuf::from);
|
||||
|
||||
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);
|
||||
|
||||
match std::fs::metadata(&file_path) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
use crate::util::file::external_editor;
|
||||
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenCodeInWarp {
|
||||
path: file_path,
|
||||
layout: external_editor::settings::EditorLayout::SplitPane,
|
||||
line_col: parsed_path.line_and_column_num,
|
||||
});
|
||||
}
|
||||
Ok(_) => {
|
||||
show_error_toast(
|
||||
"The /open-file command only works for files, not directories"
|
||||
.to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
Err(_) => {
|
||||
show_error_toast(
|
||||
format!("File not found: {}", file_path.display()),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
use crate::server::telemetry::PaletteSource;
|
||||
|
||||
ctx.emit(Event::OpenFilesPalette {
|
||||
source: PaletteSource::Keybinding,
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
show_error_toast(
|
||||
"The /open-file command is not supported in this build".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export_to_clipboard if command.name == commands::EXPORT_TO_CLIPBOARD.name => {
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let Some(conversation) = history
|
||||
.as_ref(ctx)
|
||||
.active_conversation(self.terminal_view_id)
|
||||
else {
|
||||
show_error_toast("No active conversation to export".to_owned(), ctx);
|
||||
return true;
|
||||
};
|
||||
|
||||
let action_model = self.ai_action_model.as_ref(ctx);
|
||||
let conversation_text = conversation.export_to_markdown(Some(action_model));
|
||||
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(conversation_text));
|
||||
|
||||
// Show a toast to confirm the export
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
let toast = DismissibleToast::default(String::from(
|
||||
"Conversation exported to clipboard",
|
||||
));
|
||||
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
||||
});
|
||||
}
|
||||
export_to_file if command.name == commands::EXPORT_TO_FILE.name => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
self.export_conversation_to_file(
|
||||
argument.map(|filename| filename.to_owned()),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
show_error_toast(
|
||||
"Export conversation to file unsupported in web".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
index if command.name == commands::INDEX.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::IndexProjectSpeedbump);
|
||||
}
|
||||
init if command.name == commands::INIT.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::InitProject);
|
||||
}
|
||||
changelog if command.name == commands::CHANGELOG.name => {
|
||||
if !FeatureFlag::Changelog.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ViewLatestChangelog);
|
||||
}
|
||||
feedback if command.name == commands::FEEDBACK.name => {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SendFeedback);
|
||||
}
|
||||
open_code_review if command.name == commands::OPEN_CODE_REVIEW.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleCodeReviewPane {
|
||||
entrypoint: CodeReviewPaneEntrypoint::SlashCommand,
|
||||
});
|
||||
}
|
||||
open_mcp_servers if command.name == commands::OPEN_MCP_SERVERS.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenViewMCPPane);
|
||||
}
|
||||
open_settings_file if command.name == commands::OPEN_SETTINGS_FILE.name => {
|
||||
if !FeatureFlag::SettingsFile.is_enabled() || !cfg!(feature = "local_fs") {
|
||||
return false;
|
||||
}
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::OpenSettingsFile);
|
||||
}
|
||||
open_project_rules if command.name == commands::OPEN_PROJECT_RULES.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenProjectRulesPane);
|
||||
}
|
||||
open_rules if command.name == commands::OPEN_RULES.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenRulesPane);
|
||||
}
|
||||
edit_skill if command.name == commands::EDIT_SKILL.name => {
|
||||
if !FeatureFlag::ListSkills.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
// Open the skill selector menu - user will select a skill from the inline menu
|
||||
self.open_skill_selector(ctx);
|
||||
}
|
||||
invoke_skill if command.name == commands::INVOKE_SKILL.name => {
|
||||
if !FeatureFlag::ListSkills.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
profiles if command.name == commands::PROFILE.name => {
|
||||
if !FeatureFlag::InlineProfileSelector.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.open_profile_selector(ctx);
|
||||
}
|
||||
prompts if command.name == commands::PROMPTS.name => {
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
self.open_prompts_menu(ctx);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
rewind if command.name == commands::REWIND.name => {
|
||||
self.open_rewind_menu(ctx);
|
||||
}
|
||||
pr_comments if command.name == commands::PR_COMMENTS.name => {
|
||||
if !FeatureFlag::PRCommentsSlashCommand.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(repo_path) = self
|
||||
.active_session_path_if_local(ctx)
|
||||
.map(|path| path.to_path_buf())
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
else {
|
||||
log::error!("Expected a valid working directory since /pr-comments is only available from the terminal");
|
||||
return false;
|
||||
};
|
||||
|
||||
self.ai_controller.update(ctx, move |controller, ctx| {
|
||||
controller.send_slash_command_request(
|
||||
SlashCommandRequest::FetchReviewComments { repo_path },
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
usage if command.name == commands::USAGE.name => {
|
||||
ctx.dispatch_typed_action(&TerminalAction::OpenBillingAndUsagePane);
|
||||
}
|
||||
remote_control if command.name == commands::REMOTE_CONTROL.name => {
|
||||
if !FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
|| !FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.model
|
||||
.lock()
|
||||
.shared_session_status()
|
||||
.is_sharer_or_viewer()
|
||||
{
|
||||
show_error_toast("Session is already being shared".to_owned(), ctx);
|
||||
return true;
|
||||
}
|
||||
ctx.emit(Event::StartRemoteControl);
|
||||
}
|
||||
cost if command.name == commands::COST.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 conversation cost: no active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
} else if conversation.is_some_and(|c| c.is_empty()) {
|
||||
show_error_toast(
|
||||
"Cannot show conversation cost: conversation is empty".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
} else if conversation.is_some_and(|c| !c.status().is_done()) {
|
||||
show_error_toast(
|
||||
"Cannot show conversation cost: conversation is in progress".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
|
||||
}
|
||||
}
|
||||
fork if command.name == commands::FORK.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast("/fork requires an active conversation".to_owned(), ctx);
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::NewTab
|
||||
} else {
|
||||
ForkedConversationDestination::SplitPane
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
fork_from_exchange: None,
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
destination,
|
||||
});
|
||||
}
|
||||
fork_from if command.name == commands::FORK_FROM.name => {
|
||||
self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx);
|
||||
return true;
|
||||
}
|
||||
fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast(
|
||||
"/fork-and-compact requires an active conversation".to_owned(),
|
||||
ctx,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
let destination = if trigger.is_cmd_or_ctrl_enter() {
|
||||
ForkedConversationDestination::SplitPane
|
||||
} else {
|
||||
ForkedConversationDestination::CurrentPane
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id,
|
||||
fork_from_exchange: None,
|
||||
summarize_after_fork: true,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
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;
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: None,
|
||||
initial_prompt: argument.cloned(),
|
||||
});
|
||||
}
|
||||
queue if command.name == commands::QUEUE.name => {
|
||||
let Some(conversation_id) = self
|
||||
.ai_context_model
|
||||
.as_ref(ctx)
|
||||
.selected_conversation_id(ctx)
|
||||
else {
|
||||
show_error_toast("/queue requires an active conversation".to_owned(), ctx);
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(prompt) = argument.filter(|a| !a.is_empty()).cloned() else {
|
||||
show_error_toast("/queue requires a prompt argument".to_owned(), ctx);
|
||||
return true;
|
||||
};
|
||||
|
||||
let history = BlocklistAIHistoryModel::handle(ctx);
|
||||
let is_in_progress = history
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|c| c.status().is_in_progress() || c.status().is_blocked());
|
||||
|
||||
if is_in_progress {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::QueuePromptForConversation {
|
||||
prompt,
|
||||
});
|
||||
} else {
|
||||
self.submit_queued_prompt(prompt, ctx);
|
||||
}
|
||||
}
|
||||
open_repo if command.name == commands::OPEN_REPO.name => {
|
||||
if !FeatureFlag::InlineRepoMenu.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
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 =>
|
||||
{
|
||||
// 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
|
||||
// of handling user queries with specific slash command prefixes.
|
||||
return false;
|
||||
}
|
||||
_ => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"Attempted to execute slash command with no handler: {}",
|
||||
command.name
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Leave the buffer alone when re-sending a queued prompt (the user may have typed
|
||||
// new input while the agent was busy).
|
||||
if !is_queued_prompt {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// If the command must be executed in AI mode, and we're not already in an agent view,
|
||||
// enter the agent view.
|
||||
if FeatureFlag::AgentView.is_enabled()
|
||||
&& command.auto_enter_ai_mode
|
||||
&& !self.agent_view_controller.as_ref(ctx).is_active()
|
||||
{
|
||||
self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
let _ = controller.try_enter_agent_view(
|
||||
None,
|
||||
AgentViewEntryOrigin::SlashCommand {
|
||||
trigger: SlashCommandTrigger::input(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let is_in_agent_view = FeatureFlag::AgentView.is_enabled()
|
||||
&& self.agent_view_controller.as_ref(ctx).is_active();
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SlashCommandAccepted {
|
||||
command_details: SlashCommandAcceptedDetails::StaticCommand {
|
||||
command_name: command.name.to_owned(),
|
||||
},
|
||||
is_in_agent_view,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Handles cmd+enter (Mac) / ctrl+enter (Linux/Windows) for slash commands.
|
||||
///
|
||||
/// Returns `true` if the keypress was handled.
|
||||
pub(super) fn maybe_handle_cmd_or_ctrl_shift_enter_for_slash_command(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
// If slash command menu is open, accept the selected item with cmd_or_ctrl_enter=true.
|
||||
if matches!(
|
||||
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);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no menu but slash command detected in buffer, execute with cmd_or_ctrl_enter=true
|
||||
match self.slash_command_model.as_ref(ctx).state() {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::cmd_or_ctrl_enter(),
|
||||
/*is_queued_prompt*/ false,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::DisabledUntilEmptyBuffer => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a slash command on `enter` keypress.
|
||||
///
|
||||
/// If the slash command menu is open, then "accepts" the slash command:
|
||||
/// * If the slash command does not take arguments, executes it
|
||||
/// * If the slash command does take arguments, inserts it into the input.
|
||||
///
|
||||
/// If the slash command menu is not open, then "executes" the slash command in the input, if
|
||||
/// there is one.
|
||||
///
|
||||
/// Returns `true` if the enter keypress was 'handled', else upstream enter keypress handling
|
||||
/// logic should continue.
|
||||
pub(super) fn maybe_handle_enter_for_slash_command(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
if matches!(
|
||||
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);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
match self.slash_command_model.as_ref(ctx).state() {
|
||||
SlashCommandEntryState::SlashCommand(detected_command) => {
|
||||
let command = detected_command.command.clone();
|
||||
let argument = detected_command.argument.clone();
|
||||
self.execute_slash_command(
|
||||
&command,
|
||||
argument.as_ref(),
|
||||
SlashCommandTrigger::input(),
|
||||
/*is_queued_prompt*/ false,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::DisabledUntilEmptyBuffer => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{Expanded, Highlight, Icon, ParentElement, Shrinkable};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::prelude::{ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Text};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
|
||||
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);
|
||||
|
||||
let longest_slash_command_len = COMMAND_REGISTRY
|
||||
.all_commands()
|
||||
.max_by_key(|command| command.name.len())
|
||||
.expect("static commands is non-empty")
|
||||
.name
|
||||
.len();
|
||||
|
||||
app.font_cache().em_width(
|
||||
appearance.monospace_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
) * (longest_slash_command_len as f32 + 3.)
|
||||
+ 72.
|
||||
}
|
||||
|
||||
impl SearchItem for InlineItem {
|
||||
type Action = AcceptSlashCommandOrSavedPrompt;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::new(self.icon_path, icon_color.into_solid()).finish())
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_color = inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let mut name_text = Text::new_inline(self.name.clone(), self.font_family, font_size)
|
||||
.with_color(primary_text_color.into());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let keystroke = if highlight_state.is_selected()
|
||||
&& matches!(
|
||||
&self.action,
|
||||
AcceptSlashCommandOrSavedPrompt::SlashCommand { .. }
|
||||
) {
|
||||
keybinding_name_to_keystroke(&self.name, app)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let name_element = if let Some(keystroke) = keystroke {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(name_text.finish())
|
||||
.with_child(
|
||||
Text::new(
|
||||
" or ",
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(secondary_color.into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(render_keystroke_with_color_overrides(
|
||||
&keystroke,
|
||||
None,
|
||||
Some(theme.surface_overlay_3().into_solid()),
|
||||
app,
|
||||
))
|
||||
.with_margin_left(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., Empty::new().finish()).finish())
|
||||
.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()
|
||||
} else {
|
||||
name_element
|
||||
});
|
||||
|
||||
if let Some(description) = self.description.clone() {
|
||||
let mut description_text =
|
||||
Text::new_inline(description, appearance.ui_font_family(), font_size)
|
||||
.with_color(secondary_color.into());
|
||||
|
||||
// Add bold highlighting for matching characters in the description
|
||||
if let Some(description_match) = &self.description_match_result {
|
||||
if !description_match.matched_indices.is_empty() {
|
||||
description_text = description_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold)),
|
||||
description_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
row.add_child(Expanded::new(1., description_text.finish()).finish());
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
self.action.clone()
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.action.clone()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("{:?}", self.action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ai::skills::SkillReference;
|
||||
use lazy_static::lazy_static;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{AppContext, Element, ViewContext};
|
||||
use warpui::{Entity, ModelHandle, View, ViewHandle};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::{AddAsyncSourceOptions, SearchMixer};
|
||||
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_commands::{
|
||||
AcceptSlashCommandOrSavedPrompt, SlashCommandDataSource, ZeroStateDataSource,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref SLASH_COMMAND_FILTERS: HashSet<QueryFilter> =
|
||||
HashSet::from([QueryFilter::StaticSlashCommands]);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CloseReason {
|
||||
NoResults,
|
||||
ManualDismissal,
|
||||
}
|
||||
|
||||
impl CloseReason {
|
||||
pub fn is_manual_dismissal(&self) -> bool {
|
||||
matches!(self, Self::ManualDismissal)
|
||||
}
|
||||
|
||||
pub fn is_no_results(&self) -> bool {
|
||||
matches!(self, Self::NoResults)
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by the slash commands menu
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SlashCommandsEvent {
|
||||
Close(CloseReason),
|
||||
SelectedSavedPrompt {
|
||||
id: SyncId,
|
||||
},
|
||||
/// `cmd_or_ctrl_enter` is true if accepted via Cmd/Ctrl+Enter (vs Enter/click).
|
||||
SelectedStaticCommand {
|
||||
id: SlashCommandId,
|
||||
cmd_or_ctrl_enter: bool,
|
||||
},
|
||||
/// A skill was selected from the menu. Contains the skill name (for buffer insertion)
|
||||
/// and path/bundled_skill_id (for execution context).
|
||||
SelectedSkill {
|
||||
reference: SkillReference,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Wrapper around `InlineMenuView` specialized for slash commands.
|
||||
///
|
||||
/// This view:
|
||||
/// - Creates and owns the slash command data sources
|
||||
/// - Sets up the mixer with those sources
|
||||
/// - Maps `InlineMenuEvent<SelectItem>` to `SlashCommandsEvent`
|
||||
/// - Subscribes to `SlashCommandModel` for query updates
|
||||
pub struct InlineSlashCommandView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptSlashCommandOrSavedPrompt>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptSlashCommandOrSavedPrompt>>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlineSlashCommandView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
slash_command_model: &ModelHandle<SlashCommandModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
slash_commands_source: ModelHandle<SlashCommandDataSource>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&slash_commands_source,
|
||||
|me, _, _: &UpdatedActiveCommands, ctx| {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
// Auto-rerun queries if set of active commands changed.
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let zero_state_source = ctx.add_model(|_| ZeroStateDataSource::new(&slash_commands_source));
|
||||
let saved_prompts_source = super::saved_prompts_data_source();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptSlashCommandOrSavedPrompt>::new();
|
||||
// All sources share the StaticSlashCommands filter because the mixer only runs
|
||||
// async sources when the query's filters intersect with the source's filters.
|
||||
mixer.add_sync_source(
|
||||
slash_commands_source.clone(),
|
||||
[QueryFilter::StaticSlashCommands],
|
||||
);
|
||||
mixer.add_async_source(
|
||||
saved_prompts_source,
|
||||
[QueryFilter::StaticSlashCommands],
|
||||
AddAsyncSourceOptions {
|
||||
// Any debounce makes the loading state flicker longer.
|
||||
debounce_interval: None,
|
||||
run_in_zero_state: false,
|
||||
run_when_unfiltered: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer.add_sync_source(
|
||||
zero_state_source.clone(),
|
||||
[QueryFilter::StaticSlashCommands],
|
||||
);
|
||||
mixer.run_query(slash_command_query(""), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item,
|
||||
cmd_or_ctrl_shift_enter,
|
||||
} => {
|
||||
me.handle_selection(item, *cmd_or_ctrl_shift_enter, ctx);
|
||||
}
|
||||
InlineMenuEvent::NoResults => {
|
||||
if me.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
ctx.emit(SlashCommandsEvent::Close(CloseReason::NoResults));
|
||||
}
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(SlashCommandsEvent::Close(CloseReason::ManualDismissal));
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::TabChanged => (),
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(slash_command_model, |me, model, _, ctx| {
|
||||
// If the inline menu isn't open, don't keep re-running search as the user types.
|
||||
//
|
||||
// This prevents expensive searching (e.g. saved prompts) when the menu has been
|
||||
// closed (such as after selecting a command and typing an argument).
|
||||
if !me.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
return;
|
||||
}
|
||||
|
||||
match model.as_ref(ctx).state().clone() {
|
||||
SlashCommandEntryState::None
|
||||
| SlashCommandEntryState::Composing { .. }
|
||||
| SlashCommandEntryState::SlashCommand(_) => {
|
||||
me.run_query_for_current_slash_filter(ctx);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, _, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if me.suggestions_mode_model.as_ref(ctx).is_closed() {
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If the menu reopened while the buffer still contains a slash query,
|
||||
// ensure we run a query so the menu isn't showing stale/empty results.
|
||||
if me.suggestions_mode_model.as_ref(ctx).is_slash_commands() {
|
||||
me.run_query_for_current_slash_filter(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_query_for_current_slash_filter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(filter) = self
|
||||
.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.strip_prefix('/')
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.mixer.update(ctx, move |mixer, ctx| {
|
||||
if mixer.current_query().is_some_and(|q| q.text == filter) {
|
||||
return;
|
||||
}
|
||||
mixer.run_query(slash_command_query(&filter), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_selection(
|
||||
&mut self,
|
||||
item: &AcceptSlashCommandOrSavedPrompt,
|
||||
cmd_or_ctrl_enter: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match item {
|
||||
AcceptSlashCommandOrSavedPrompt::SlashCommand { id } => {
|
||||
ctx.emit(SlashCommandsEvent::SelectedStaticCommand {
|
||||
id: *id,
|
||||
cmd_or_ctrl_enter,
|
||||
});
|
||||
}
|
||||
AcceptSlashCommandOrSavedPrompt::SavedPrompt { id } => {
|
||||
ctx.emit(SlashCommandsEvent::SelectedSavedPrompt { id: *id });
|
||||
}
|
||||
AcceptSlashCommandOrSavedPrompt::Skill { name, reference } => {
|
||||
ctx.emit(SlashCommandsEvent::SelectedSkill {
|
||||
reference: reference.clone(),
|
||||
name: name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(cmd_or_ctrl_enter, ctx));
|
||||
}
|
||||
|
||||
pub fn result_count(&self, app: &AppContext) -> usize {
|
||||
self.mixer.as_ref(app).results().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineSlashCommandView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineSlashCommandView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Query that includes the StaticSlashCommands filter so both sync and
|
||||
/// async sources run.
|
||||
fn slash_command_query(text: &str) -> Query {
|
||||
Query {
|
||||
text: text.to_owned(),
|
||||
filters: SLASH_COMMAND_FILTERS.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineSlashCommandView {
|
||||
type Event = SlashCommandsEvent;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Rendering logic for the input suggestions menu.
|
||||
//!
|
||||
//! This module contains all rendering functions for the various input suggestion modes:
|
||||
//! - HistoryUp
|
||||
//! - CompletionSuggestions
|
||||
//! - StaticWorkflowEnumSuggestions
|
||||
//! - DynamicWorkflowEnumSuggestions
|
||||
|
||||
use super::{
|
||||
DynamicEnumSuggestionStatus, Input, InputAction, MenuPositioning, DYNAMIC_ENUM_FAILURE_MESSAGE,
|
||||
DYNAMIC_ENUM_GENERATE_MESSAGE, DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING,
|
||||
DYNAMIC_ENUM_MENU_HEIGHT_OFFSET, DYNAMIC_ENUM_MENU_PADDING, DYNAMIC_ENUM_NO_RESULTS_MESSAGE,
|
||||
DYNAMIC_ENUM_PENDING_MESSAGE, DYNAMIC_ENUM_RUN_MESSAGE, HISTORY_DETAILS_VIEW_WIDTH_REQUIREMENT,
|
||||
RUN_DYNAMIC_ENUM_COMMAND_KEYSTROKE, TERMINAL_VIEW_PADDING_LEFT,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::input_suggestions::{
|
||||
DETAILS_PANEL_MARGIN, DETAILS_PANEL_PADDING, HISTORY_DETAILS_PANEL_WIDTH,
|
||||
LABEL_PADDING as InputSuggestionsLabelPadding,
|
||||
};
|
||||
use crate::themes::theme::WarpTheme;
|
||||
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};
|
||||
|
||||
enum SuggestionsResizeConfig {
|
||||
WidthAndHeight,
|
||||
WidthOnly,
|
||||
HeightOnly,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub(super) fn render_history_up_menu(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let radius = Radius::Pixels(6.);
|
||||
|
||||
let corner_radius = match menu_positioning {
|
||||
MenuPositioning::AboveInputBox => CornerRadius::with_top(radius),
|
||||
MenuPositioning::BelowInputBox => CornerRadius::with_bottom(radius),
|
||||
};
|
||||
|
||||
let input_suggestions_border = 2.0;
|
||||
let margin =
|
||||
*TERMINAL_VIEW_PADDING_LEFT - input_suggestions_border - InputSuggestionsLabelPadding;
|
||||
|
||||
let content = ChildView::new(&self.input_suggestions).finish();
|
||||
|
||||
SizeConstraintSwitch::new(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
self.render_suggestions_container(
|
||||
margin,
|
||||
corner_radius,
|
||||
theme,
|
||||
SuggestionsResizeConfig::HeightOnly,
|
||||
menu_positioning,
|
||||
content,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
ConstrainedBox::new(Empty::new().finish())
|
||||
.with_width(
|
||||
HISTORY_DETAILS_PANEL_WIDTH
|
||||
+ DETAILS_PANEL_MARGIN * 2.
|
||||
+ DETAILS_PANEL_PADDING * 2.,
|
||||
)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(HISTORY_DETAILS_VIEW_WIDTH_REQUIREMENT),
|
||||
self.render_suggestions_container(
|
||||
margin,
|
||||
corner_radius,
|
||||
theme,
|
||||
SuggestionsResizeConfig::HeightOnly,
|
||||
menu_positioning,
|
||||
ChildView::new(&self.input_suggestions).finish(),
|
||||
),
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub(super) fn render_completion_suggestions_menu(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let corner_radius = CornerRadius::with_all(Radius::Pixels(6.));
|
||||
|
||||
let content = ChildView::new(&self.input_suggestions).finish();
|
||||
|
||||
self.render_suggestions_container(
|
||||
0.,
|
||||
corner_radius,
|
||||
theme,
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
menu_positioning,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_workflow_enum_suggestions_menu(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let corner_radius = CornerRadius::with_all(Radius::Pixels(6.));
|
||||
|
||||
// Same styling as CompletionSuggestions
|
||||
let margin = 0.;
|
||||
|
||||
let content = ChildView::new(&self.input_suggestions).finish();
|
||||
|
||||
self.render_suggestions_container(
|
||||
margin,
|
||||
corner_radius,
|
||||
theme,
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
menu_positioning,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_dynamic_workflow_enum_menu(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
command: String,
|
||||
status: DynamicEnumSuggestionStatus,
|
||||
suggestions: &[String],
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let corner_radius = CornerRadius::with_all(Radius::Pixels(6.));
|
||||
|
||||
let (content, resize_config) = match status {
|
||||
DynamicEnumSuggestionStatus::Unapproved => (
|
||||
self.render_dynamic_enum_command_approval(command, appearance),
|
||||
SuggestionsResizeConfig::WidthOnly,
|
||||
),
|
||||
DynamicEnumSuggestionStatus::Pending => (
|
||||
self.render_dynamic_enum_status_message(DYNAMIC_ENUM_PENDING_MESSAGE, appearance),
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
),
|
||||
DynamicEnumSuggestionStatus::Failure => (
|
||||
self.render_dynamic_enum_status_message(DYNAMIC_ENUM_FAILURE_MESSAGE, appearance),
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
),
|
||||
DynamicEnumSuggestionStatus::Success if suggestions.is_empty() => (
|
||||
self.render_dynamic_enum_status_message(
|
||||
DYNAMIC_ENUM_NO_RESULTS_MESSAGE,
|
||||
appearance,
|
||||
),
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
),
|
||||
DynamicEnumSuggestionStatus::Success => (
|
||||
ChildView::new(&self.input_suggestions).finish(),
|
||||
SuggestionsResizeConfig::WidthAndHeight,
|
||||
),
|
||||
};
|
||||
|
||||
self.render_suggestions_container(
|
||||
0.,
|
||||
corner_radius,
|
||||
theme,
|
||||
resize_config,
|
||||
menu_positioning,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the suggestions container with configurable resize behavior.
|
||||
fn render_suggestions_container(
|
||||
&self,
|
||||
margin: f32,
|
||||
corner_radius: CornerRadius,
|
||||
theme: &WarpTheme,
|
||||
resize_config: SuggestionsResizeConfig,
|
||||
menu_positioning: MenuPositioning,
|
||||
content: Box<dyn Element>,
|
||||
) -> Box<dyn Element> {
|
||||
// Inner container for the visual menu content (background, border, shadow)
|
||||
// This is wrapped by Resizable so the resize handles align with the border.
|
||||
let inner_container = Container::new(content)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(corner_radius)
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.with_border(Border::all(1.0).with_border_fill(theme.outline()))
|
||||
.finish();
|
||||
|
||||
// Apply width resizing based on config
|
||||
let horizontal_resizable = match resize_config {
|
||||
SuggestionsResizeConfig::WidthAndHeight | SuggestionsResizeConfig::WidthOnly => {
|
||||
let width_handle_for_end = self.completions_menu_resizable_width.clone();
|
||||
Resizable::new(
|
||||
self.completions_menu_resizable_width.clone(),
|
||||
inner_container,
|
||||
)
|
||||
.with_dragbar_side(DragBarSide::Right)
|
||||
.with_dragbar_offset(7.0)
|
||||
.with_bounds_callback(Box::new(|window_size| (200.0, window_size.x())))
|
||||
.on_resize(move |ctx, _| {
|
||||
ctx.notify();
|
||||
})
|
||||
.on_end_resizing(move |ctx, _| {
|
||||
let new_width = width_handle_for_end
|
||||
.lock()
|
||||
.expect("width handle lock poisoned")
|
||||
.size();
|
||||
ctx.dispatch_typed_action(InputAction::UpdateCompletionsMenuWidth(new_width));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
SuggestionsResizeConfig::HeightOnly => inner_container,
|
||||
};
|
||||
|
||||
// Apply height resizing based on config
|
||||
// Skip vertical resizing for command approval to show full command because we want users
|
||||
// to see the entire command they are about to execute.
|
||||
let resizable_element = match resize_config {
|
||||
SuggestionsResizeConfig::WidthOnly => horizontal_resizable,
|
||||
SuggestionsResizeConfig::WidthAndHeight | SuggestionsResizeConfig::HeightOnly => {
|
||||
let dragbar_side = match menu_positioning {
|
||||
MenuPositioning::AboveInputBox => DragBarSide::Top,
|
||||
MenuPositioning::BelowInputBox => DragBarSide::Bottom,
|
||||
};
|
||||
|
||||
let height_handle_for_end = self.completions_menu_resizable_height.clone();
|
||||
Resizable::new(
|
||||
self.completions_menu_resizable_height.clone(),
|
||||
horizontal_resizable,
|
||||
)
|
||||
.with_dragbar_side(dragbar_side)
|
||||
.with_bounds_callback(Box::new(|window_size| (100.0, window_size.y())))
|
||||
.on_resize(move |ctx, _| {
|
||||
ctx.notify();
|
||||
})
|
||||
.on_end_resizing(move |ctx, _| {
|
||||
let new_height = height_handle_for_end
|
||||
.lock()
|
||||
.expect("height handle lock poisoned")
|
||||
.size();
|
||||
ctx.dispatch_typed_action(InputAction::UpdateCompletionsMenuHeight(new_height));
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
Container::new(resizable_element)
|
||||
.with_margin_top(12.0)
|
||||
.with_margin_left(margin)
|
||||
.with_margin_right(margin)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders the command approval UI for dynamic enum suggestions.
|
||||
fn render_dynamic_enum_command_approval(
|
||||
&self,
|
||||
command: String,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let keybinding_style = UiComponentStyles {
|
||||
height: Some(15.),
|
||||
font_size: Some(appearance.ui_font_size()),
|
||||
margin: Some(Coords {
|
||||
left: 2.,
|
||||
right: 2.,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
String::from(DYNAMIC_ENUM_GENERATE_MESSAGE),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.surface_2()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(DYNAMIC_ENUM_MENU_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(appearance.ui_font_size() + DYNAMIC_ENUM_MENU_HEIGHT_OFFSET)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
command.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.background()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(DYNAMIC_ENUM_MENU_PADDING)
|
||||
.with_background(theme.background())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.keyboard_shortcut(&RUN_DYNAMIC_ENUM_COMMAND_KEYSTROKE)
|
||||
.with_style(keybinding_style)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
String::from(DYNAMIC_ENUM_RUN_MESSAGE),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(appearance.ui_font_size() + DYNAMIC_ENUM_MENU_HEIGHT_OFFSET)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a status message for dynamic enum suggestions.
|
||||
fn render_dynamic_enum_status_message(
|
||||
&self,
|
||||
message: &str,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
ConstrainedBox::new(
|
||||
Align::new(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
String::from(message),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(DYNAMIC_ENUM_HORIZONTAL_TEXT_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(appearance.monospace_font_size() + DYNAMIC_ENUM_MENU_HEIGHT_OFFSET)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use warpui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
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,
|
||||
/// Buffer state saved when an inline menu is opened, so it can be restored on dismiss.
|
||||
buffer_to_restore: Option<BufferState>,
|
||||
/// Handle to the input buffer model, used to snapshot buffer state when opening menus.
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InputSuggestionsModeModel {
|
||||
pub fn new(buffer_model: ModelHandle<InputBufferModel>) -> Self {
|
||||
Self {
|
||||
mode: InputSuggestionsMode::Closed,
|
||||
buffer_to_restore: None,
|
||||
buffer_model,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> &InputSuggestionsMode {
|
||||
&self.mode
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: InputSuggestionsMode, ctx: &mut ModelContext<Self>) {
|
||||
if self.mode == mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let input_config_to_restore = self.mode.input_config_to_restore();
|
||||
|
||||
// If we're setting a new non-closed mode while the current mode is also non-closed,
|
||||
// first emit a mode change for the implicit close before transitioning to the new mode.
|
||||
if self.is_visible() && !matches!(mode, InputSuggestionsMode::Closed) {
|
||||
self.mode = InputSuggestionsMode::Closed;
|
||||
ctx.emit(InputSuggestionsModeEvent::ModeChanged {
|
||||
buffer_to_restore: None,
|
||||
input_config_to_restore,
|
||||
});
|
||||
}
|
||||
|
||||
// Snapshot the buffer state when opening a mode that supports buffer restoration.
|
||||
if mode.should_snapshot_and_restore_buffer() {
|
||||
let buffer_model = self.buffer_model.as_ref(ctx);
|
||||
self.buffer_to_restore = Some(BufferState::new(
|
||||
buffer_model.current_value().to_owned(),
|
||||
buffer_model.cursor_point(),
|
||||
));
|
||||
}
|
||||
|
||||
// When closing via set_mode, we always discard saved buffer state.
|
||||
// To restore buffer state, callers should use close_and_restore_buffer.
|
||||
if matches!(mode, InputSuggestionsMode::Closed) {
|
||||
self.buffer_to_restore = None;
|
||||
}
|
||||
|
||||
self.mode = mode;
|
||||
ctx.emit(InputSuggestionsModeEvent::ModeChanged {
|
||||
buffer_to_restore: None,
|
||||
input_config_to_restore: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the current menu, restoring the buffer if it was snapshotted on open.
|
||||
pub fn close_and_restore_buffer(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.is_closed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer_to_restore = self.buffer_to_restore.take();
|
||||
let input_config_to_restore = self.mode.input_config_to_restore();
|
||||
self.mode = InputSuggestionsMode::Closed;
|
||||
ctx.emit(InputSuggestionsModeEvent::ModeChanged {
|
||||
buffer_to_restore,
|
||||
input_config_to_restore,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_dynamic_enum_status(
|
||||
&mut self,
|
||||
status: DynamicEnumSuggestionStatus,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
|
||||
dynamic_enum_status,
|
||||
..
|
||||
} = &mut self.mode
|
||||
{
|
||||
*dynamic_enum_status = status;
|
||||
ctx.emit(InputSuggestionsModeEvent::ModeChanged {
|
||||
buffer_to_restore: None,
|
||||
input_config_to_restore: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.mode.is_visible()
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::Closed)
|
||||
}
|
||||
|
||||
pub fn is_history_up(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::HistoryUp { .. })
|
||||
}
|
||||
|
||||
pub fn is_completion_suggestions(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
InputSuggestionsMode::CompletionSuggestions { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_static_workflow_enum_suggestions(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_dynamic_workflow_enum_suggestions(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_ai_context_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::AIContextMenu { .. })
|
||||
}
|
||||
|
||||
pub fn is_slash_commands(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::SlashCommands)
|
||||
}
|
||||
|
||||
pub fn is_conversation_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::ConversationMenu)
|
||||
}
|
||||
|
||||
pub fn is_inline_model_selector(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::ModelSelector)
|
||||
}
|
||||
|
||||
pub fn is_profile_selector(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::ProfileSelector)
|
||||
}
|
||||
|
||||
pub fn is_prompts_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::PromptsMenu)
|
||||
}
|
||||
|
||||
pub fn is_skill_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::SkillMenu)
|
||||
}
|
||||
|
||||
pub fn is_user_query_menu(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: super::UserQueryMenuAction::ForkFrom,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_rewind_menu(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: super::UserQueryMenuAction::Rewind,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the conversation_id if the current mode is UserQueryMenu (ForkFrom).
|
||||
pub fn user_query_conversation_id(&self) -> Option<AIConversationId> {
|
||||
match &self.mode {
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: super::UserQueryMenuAction::ForkFrom,
|
||||
conversation_id,
|
||||
} => Some(*conversation_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conversation_id if the current mode is RewindMenu.
|
||||
pub fn rewind_conversation_id(&self) -> Option<AIConversationId> {
|
||||
match &self.mode {
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: super::UserQueryMenuAction::Rewind,
|
||||
conversation_id,
|
||||
} => Some(*conversation_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_inline_history_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::InlineHistoryMenu { .. })
|
||||
}
|
||||
|
||||
pub fn is_repos_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::IndexedReposMenu)
|
||||
}
|
||||
|
||||
pub fn is_plan_menu(&self) -> bool {
|
||||
matches!(self.mode, InputSuggestionsMode::PlanMenu { .. })
|
||||
}
|
||||
|
||||
/// Returns the conversation_id if the current mode is PlanMenu.
|
||||
pub fn plan_menu_conversation_id(&self) -> Option<AIConversationId> {
|
||||
match &self.mode {
|
||||
InputSuggestionsMode::PlanMenu { conversation_id } => Some(*conversation_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_menu_type(&self) -> Option<InlineMenuType> {
|
||||
InlineMenuType::from_suggestions_mode(&self.mode)
|
||||
}
|
||||
|
||||
pub fn is_inline_menu_open(&self) -> bool {
|
||||
self.mode.is_inline_menu()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InputSuggestionsModeModel {
|
||||
type Event = InputSuggestionsModeEvent;
|
||||
}
|
||||
|
||||
pub enum InputSuggestionsModeEvent {
|
||||
ModeChanged {
|
||||
/// The saved buffer state to restore, if this mode change is an inline menu closing.
|
||||
/// `None` for all other transitions.
|
||||
buffer_to_restore: Option<BufferState>,
|
||||
/// The saved input config to restore, if this mode change closes inline history menu
|
||||
/// without accepting the temporary preview state.
|
||||
input_config_to_restore: Option<InputConfig>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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 warp_core::settings::Setting;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Clipped, Container, DropTarget, Element, Flex, Hoverable, ParentElement,
|
||||
SavePosition, Stack,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
|
||||
impl Input {
|
||||
/// Renders the terminal mode input when `FeatureFlag::AgentView` is enabled and there is no
|
||||
/// active agent view.
|
||||
pub(super) fn render_terminal_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let vim_state = self.editor.as_ref(app).vim_state(app);
|
||||
let app_editor_settings = AppEditorSettings::as_ref(app);
|
||||
let show_vim_status = vim_state.is_some() && *app_editor_settings.vim_status_bar.value();
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToBottom | InputMode::Waterfall) {
|
||||
if let Some(banner) = self.render_input_banner(appearance, app, input_mode, false) {
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let prompt_elements = self
|
||||
.prompt_render_helper
|
||||
.render_universal_developer_input_prompt(&model, appearance, app);
|
||||
|
||||
column.add_child(prompt_elements);
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
column.add_child(
|
||||
Container::new(self.render_input_box(show_vim_status, appearance, app))
|
||||
.with_margin_top(
|
||||
terminal_spacing.prompt_to_editor_padding
|
||||
* spacing::UDI_PROMPT_BOTTOM_PADDING_FACTOR,
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if should_show_terminal_input_message_bar(&model, app) {
|
||||
column.add_child(
|
||||
Clipped::new(ChildView::new(&self.terminal_input_message_bar).finish()).finish(),
|
||||
);
|
||||
} else if !(matches!(input_mode, InputMode::PinnedToTop)
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_menu_open())
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(Flex::row().finish())
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToTop) {
|
||||
if let Some(banner) = self.render_input_banner(appearance, app, input_mode, false) {
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_bottom(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.focus_handle
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.is_active_session(app)),
|
||||
column.finish(),
|
||||
false,
|
||||
));
|
||||
|
||||
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 is_focused = self.focus_handle.as_ref().is_none_or(|h| h.is_focused(app));
|
||||
if self.is_voltron_open && is_focused {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if is_focused {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let hoverable_input = Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let show_block_dividers = *BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
|
||||
let input = if show_block_dividers {
|
||||
Container::new(hoverable_input)
|
||||
.with_border(
|
||||
Border::top(1.)
|
||||
.with_border_color(styles::default_border_color(appearance.theme())),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
hoverable_input
|
||||
};
|
||||
|
||||
let mut column = Flex::column();
|
||||
let is_slash_commands = self.suggestions_mode_model.as_ref(app).is_slash_commands();
|
||||
let is_conversation_menu = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_conversation_menu();
|
||||
let is_prompts_menu = self.suggestions_mode_model.as_ref(app).is_prompts_menu();
|
||||
let is_skill_menu = self.suggestions_mode_model.as_ref(app).is_skill_menu();
|
||||
let is_inline_history_menu = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu();
|
||||
let is_repos_menu = self.suggestions_mode_model.as_ref(app).is_repos_menu();
|
||||
let hide_menu = self
|
||||
.inline_terminal_menu_positioner
|
||||
.as_ref(app)
|
||||
.should_hide_inline_menu_for_pane_size(app);
|
||||
match input_mode {
|
||||
InputMode::PinnedToBottom => {
|
||||
column.add_children(
|
||||
[
|
||||
if hide_menu {
|
||||
None
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
Some(input),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::PinnedToTop => {
|
||||
column.add_children(
|
||||
[
|
||||
Some(input),
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
if hide_menu {
|
||||
None
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::Waterfall => {
|
||||
let should_render_below = self
|
||||
.inline_terminal_menu_positioner
|
||||
.as_ref(app)
|
||||
.should_render_inline_menu_below_input();
|
||||
|
||||
if !hide_menu {
|
||||
if is_slash_commands && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && !should_render_below {
|
||||
column.add_child(
|
||||
ChildView::new(&self.inline_conversation_menu_view).finish(),
|
||||
);
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& !should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
}
|
||||
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
|
||||
if !hide_menu {
|
||||
if is_slash_commands && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && should_render_below {
|
||||
column.add_child(
|
||||
ChildView::new(&self.inline_conversation_menu_view).finish(),
|
||||
);
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
|
||||
pub fn default_border_color(theme: &WarpTheme) -> ColorU {
|
||||
theme.outline().into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
use warpui::elements::{Container, Element};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
|
||||
use super::buffer_model::InputBufferModel;
|
||||
use super::message_bar::{
|
||||
common::render_terminal_message, truncated_command_for_block, Message, MessageItem,
|
||||
MessageProvider,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIInputModel,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::terminal::input::inline_history::{AcceptHistoryItem, HistoryTab};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuModel, InlineMenuModelEvent};
|
||||
use crate::terminal::input::message_bar::MessageTransformer;
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::SET_INPUT_MODE_TERMINAL_ACTION_NAME;
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::terminal::view::init::SELECT_PREVIOUS_BLOCK_ACTION_NAME;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
|
||||
/// Renders contextual hint text at the bottom of the terminal input when `FeatureFlag::AgentView`
|
||||
/// is enabled.
|
||||
pub struct TerminalInputMessageBar {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ai_input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
inline_history_model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
|
||||
}
|
||||
|
||||
impl Entity for TerminalInputMessageBar {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TerminalInputMessageBar {
|
||||
pub fn new(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ai_input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
inline_history_model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&input_buffer_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
|
||||
if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |_, _, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&inline_history_model, |_, _, event, ctx| {
|
||||
if let InlineMenuModelEvent::UpdatedSelectedItem = event {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
terminal_model,
|
||||
ai_input_model,
|
||||
input_buffer_model,
|
||||
context_model,
|
||||
suggestions_mode_model,
|
||||
inline_history_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for TerminalInputMessageBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"TerminalInputMessageBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
let selected = self.inline_history_model.as_ref(app).selected_item();
|
||||
let message = InlineHistoryMessageProducer
|
||||
.produce_message(selected)
|
||||
.unwrap_or_default();
|
||||
return Container::new(render_terminal_message(message, app))
|
||||
.with_padding_bottom(8.)
|
||||
.with_padding_right(8.)
|
||||
.finish();
|
||||
}
|
||||
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
let current_buffer = self.input_buffer_model.as_ref(app).current_value();
|
||||
let context_model = self.context_model.as_ref(app);
|
||||
let input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
let args = TerminalMessageArgs {
|
||||
current_input: current_buffer,
|
||||
terminal_model: &terminal_model,
|
||||
context_model,
|
||||
input_model,
|
||||
app,
|
||||
};
|
||||
|
||||
let mut message = ErroredBlockMessageProducer
|
||||
.produce_message(args)
|
||||
.or_else(|| AgentMessageProducer.produce_message(args))
|
||||
.or_else(|| PlanMessageProducer.produce_message(args))
|
||||
.or_else(|| ContinueConversationMessageProducer.produce_message(args))
|
||||
.or_else(|| DefaultMessageProducer.produce_message(args))
|
||||
.unwrap_or_default();
|
||||
|
||||
let transformers: [Box<dyn MessageTransformer<TerminalMessageArgs<'_>>>; 3] = [
|
||||
Box::new(AttachedBlocksMessageTransformer),
|
||||
Box::new(AttachedTextSelectionMessageTransformer),
|
||||
Box::new(AutodetectedPromptMessageTransformer),
|
||||
];
|
||||
|
||||
for transformer in transformers {
|
||||
transformer.transform_message(&mut message, args);
|
||||
}
|
||||
|
||||
Container::new(render_terminal_message(message, app))
|
||||
.with_padding_bottom(8.)
|
||||
.with_padding_right(8.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct TerminalMessageArgs<'a> {
|
||||
current_input: &'a str,
|
||||
terminal_model: &'a TerminalModel,
|
||||
context_model: &'a BlocklistAIContextModel,
|
||||
input_model: &'a BlocklistAIInputModel,
|
||||
app: &'a AppContext,
|
||||
}
|
||||
|
||||
impl<'a> TerminalMessageArgs<'a> {
|
||||
fn is_input_ai_detected(&self) -> bool {
|
||||
!self.current_input.is_empty()
|
||||
&& self.input_model.is_ai_input_enabled()
|
||||
&& !self.input_model.is_input_type_locked()
|
||||
}
|
||||
}
|
||||
|
||||
struct ErroredBlockMessageProducer;
|
||||
impl MessageProvider<TerminalMessageArgs<'_>> for ErroredBlockMessageProducer {
|
||||
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
|
||||
let block = args.terminal_model.block_list().last_non_hidden_block()?;
|
||||
let context_block_ids = args.context_model.pending_context_block_ids();
|
||||
if block.exit_code().was_successful()
|
||||
|| !args.current_input.is_empty()
|
||||
|| !context_block_ids.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let keystroke = keybinding_name_to_keystroke(SELECT_PREVIOUS_BLOCK_ACTION_NAME, args.app)?;
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(keystroke),
|
||||
MessageItem::text(format!(
|
||||
" attach `{}` output as agent context",
|
||||
truncated_command_for_block(&block.command_to_string())
|
||||
)),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentMessageProducer;
|
||||
impl MessageProvider<TerminalMessageArgs<'_>> for AgentMessageProducer {
|
||||
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
|
||||
let TerminalMessageArgs {
|
||||
current_input, app, ..
|
||||
} = args;
|
||||
|
||||
if !current_input.starts_with(commands::AGENT.name)
|
||||
&& !current_input.starts_with(commands::NEW.name)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
Some(
|
||||
Message::new(vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" new conversation"),
|
||||
])
|
||||
.with_color(message_magenta(theme)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlanMessageProducer;
|
||||
impl MessageProvider<TerminalMessageArgs<'_>> for PlanMessageProducer {
|
||||
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
|
||||
let TerminalMessageArgs {
|
||||
current_input, app, ..
|
||||
} = args;
|
||||
|
||||
if !current_input.trim_start().starts_with(commands::PLAN.name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let is_input_ai_detected = args.is_input_ai_detected();
|
||||
|
||||
Some(
|
||||
Message::new(vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
cmd: !is_input_ai_detected && cfg!(target_os = "macos"),
|
||||
ctrl: !is_input_ai_detected && !cfg!(target_os = "macos"),
|
||||
shift: !is_input_ai_detected && !cfg!(target_os = "macos"),
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" plan with agent"),
|
||||
])
|
||||
.with_color(message_magenta(theme)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContinueConversationMessageProducer;
|
||||
impl MessageProvider<TerminalMessageArgs<'_>> for ContinueConversationMessageProducer {
|
||||
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
|
||||
let TerminalMessageArgs {
|
||||
current_input,
|
||||
terminal_model,
|
||||
..
|
||||
} = args;
|
||||
if !current_input.is_empty() || !terminal_model.is_last_visible_item_agent_view_block() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let keystroke = keybinding_name_to_keystroke(commands::CONVERSATIONS.name, args.app)?;
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(keystroke),
|
||||
MessageItem::text(" to continue conversation"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
mod internal {
|
||||
use crate::terminal::{
|
||||
model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, RichContentItem},
|
||||
TerminalModel,
|
||||
};
|
||||
|
||||
impl TerminalModel {
|
||||
pub(super) fn is_last_visible_item_agent_view_block(&self) -> bool {
|
||||
let block_list = self.block_list();
|
||||
|
||||
// When we insert rich content (including agent view blocks) we insert it immediately before
|
||||
// the active block (unless explicitly inserting below a long-running block). The active
|
||||
// block is a special "warp input" block that often exists even when it isn't user-visible.
|
||||
//
|
||||
// So, for dedupe we check the first visible (non-zero height) item *immediately before the
|
||||
// active block*. This avoids false negatives caused by the active block itself.
|
||||
let active_block_index = block_list.active_block_index();
|
||||
|
||||
let mut cursor = block_list
|
||||
.block_heights()
|
||||
.cursor::<BlockHeight, BlockHeightSummary>();
|
||||
cursor.descend_to_last_item(block_list.block_heights());
|
||||
|
||||
// Seek backwards until we're at the active block's height item.
|
||||
while let Some(item) = cursor.item() {
|
||||
match item {
|
||||
BlockHeightItem::Block(_)
|
||||
if cursor.start().block_count == active_block_index.0 =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
_ => cursor.prev(),
|
||||
}
|
||||
}
|
||||
|
||||
// Now walk backwards to find the first visible item before the active block.
|
||||
cursor.prev();
|
||||
while let Some(item) = cursor.item() {
|
||||
let is_hidden = item.height() == BlockHeight::zero();
|
||||
match item {
|
||||
BlockHeightItem::RichContent(RichContentItem { content_type, .. })
|
||||
if !is_hidden
|
||||
&& content_type
|
||||
.is_some_and(|content| content.is_agent_view_block()) =>
|
||||
{
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
if is_hidden {
|
||||
cursor.prev();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultMessageProducer;
|
||||
impl MessageProvider<TerminalMessageArgs<'_>> for DefaultMessageProducer {
|
||||
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
|
||||
let is_input_ai_detected = args.is_input_ai_detected();
|
||||
|
||||
let keystroke = if is_input_ai_detected {
|
||||
Some(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
} else if let Some(keystroke) = keybinding_name_to_keystroke(commands::AGENT.name, args.app)
|
||||
{
|
||||
Some(keystroke)
|
||||
} else {
|
||||
keybinding_name_to_keystroke(commands::NEW.name, args.app)
|
||||
};
|
||||
|
||||
if let Some(keystroke) = keystroke {
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(keystroke),
|
||||
MessageItem::text(" new /agent conversation"),
|
||||
]))
|
||||
} else {
|
||||
Some(Message::new(vec![MessageItem::text(
|
||||
"/agent for new conversation",
|
||||
)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InlineHistoryMessageProducer;
|
||||
impl MessageProvider<Option<&AcceptHistoryItem>> for InlineHistoryMessageProducer {
|
||||
fn produce_message(&self, selected: Option<&AcceptHistoryItem>) -> Option<Message> {
|
||||
let enter = MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
let items = match selected {
|
||||
Some(AcceptHistoryItem::Command { .. }) => {
|
||||
vec![enter, MessageItem::text(" to execute")]
|
||||
}
|
||||
Some(AcceptHistoryItem::AIPrompt { .. }) => {
|
||||
vec![enter, MessageItem::text(" to send")]
|
||||
}
|
||||
Some(AcceptHistoryItem::Conversation { title, .. }) => {
|
||||
vec![enter, MessageItem::text(format!(" to open '{title}'"))]
|
||||
}
|
||||
None => {
|
||||
vec![MessageItem::text("")]
|
||||
}
|
||||
};
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
|
||||
struct AutodetectedPromptMessageTransformer;
|
||||
impl MessageTransformer<TerminalMessageArgs<'_>> for AutodetectedPromptMessageTransformer {
|
||||
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
|
||||
if !args.is_input_ai_detected()
|
||||
|| args.current_input.starts_with(commands::AGENT.name)
|
||||
|| args.current_input.starts_with(commands::NEW.name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't append this message if there is attached context, just cause its
|
||||
// too much text and overwhelming.
|
||||
if args.context_model.pending_context_block_ids().is_empty()
|
||||
&& args.context_model.pending_context_selected_text().is_none()
|
||||
{
|
||||
let set_terminal_mode_keystroke =
|
||||
keybinding_name_to_keystroke(SET_INPUT_MODE_TERMINAL_ACTION_NAME, args.app)
|
||||
.unwrap_or_else(|| Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
message.items.extend([
|
||||
MessageItem::text(" (autodetected) "),
|
||||
MessageItem::keystroke(set_terminal_mode_keystroke),
|
||||
MessageItem::text(" to override"),
|
||||
]);
|
||||
}
|
||||
message.set_color(message_magenta(Appearance::as_ref(args.app).theme()));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachedBlocksMessageTransformer;
|
||||
impl MessageTransformer<TerminalMessageArgs<'_>> for AttachedBlocksMessageTransformer {
|
||||
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
|
||||
let context_block_ids = args.context_model.pending_context_block_ids();
|
||||
if context_block_ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(block_command) = context_block_ids
|
||||
.iter()
|
||||
.find_map(|id| args.terminal_model.block_list().block_with_id(id))
|
||||
.map(|block| truncated_command_for_block(&block.command_to_string()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if context_block_ids.len() == 1 {
|
||||
message.append_text(format!(" with `{}` attached", block_command).as_str());
|
||||
} else {
|
||||
let text = if context_block_ids.len() == 2 {
|
||||
format!(" with `{}` and 1 other command attached", block_command)
|
||||
} else {
|
||||
format!(
|
||||
" with `{}` and {} other commands attached",
|
||||
block_command,
|
||||
context_block_ids.len().saturating_sub(1)
|
||||
)
|
||||
};
|
||||
message.append_text(text.as_str());
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachedTextSelectionMessageTransformer;
|
||||
impl MessageTransformer<TerminalMessageArgs<'_>> for AttachedTextSelectionMessageTransformer {
|
||||
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
|
||||
if args.context_model.pending_context_selected_text().is_none()
|
||||
|| !args.context_model.pending_context_block_ids().is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
message.append_text(" with text selection attached");
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn message_magenta(theme: &WarpTheme) -> ColorU {
|
||||
let mut color = theme.ansi_fg_magenta();
|
||||
color.a = (255. * 0.65) as u8;
|
||||
color
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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 settings::Setting;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildView, Container, CornerRadius, DropTarget, Element, Flex, Hoverable,
|
||||
ParentElement, Radius, SavePosition, Stack,
|
||||
},
|
||||
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,
|
||||
},
|
||||
Input,
|
||||
};
|
||||
|
||||
impl Input {
|
||||
/// Renders the universal input. This is used when `FeatureFlag::AgentView` is disabled and the
|
||||
/// user has 'universal' input type selected in settings.
|
||||
pub(super) fn render_universal_developer_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let mut prompt_row = Stack::new();
|
||||
|
||||
let prompt_elements = self
|
||||
.prompt_render_helper
|
||||
.render_universal_developer_input_prompt(&model, appearance, app);
|
||||
prompt_row.add_child(prompt_elements);
|
||||
|
||||
let vim_state = self.editor.as_ref(app).vim_state(app);
|
||||
let app_editor_settings = AppEditorSettings::as_ref(app);
|
||||
let show_vim_status = vim_state.is_some() && *app_editor_settings.vim_status_bar.value();
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
// For Universal Developer Input, ignore compact mode setting
|
||||
let is_compact_mode = false;
|
||||
let mut column = Flex::column();
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToBottom | InputMode::Waterfall) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(prompt_row.finish());
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI)
|
||||
{
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
column.add_child(
|
||||
Container::new(self.render_input_box(show_vim_status, appearance, app))
|
||||
.with_margin_top(
|
||||
terminal_spacing.prompt_to_editor_padding
|
||||
* spacing::UDI_PROMPT_BOTTOM_PADDING_FACTOR,
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
column.add_child(ChildView::new(&self.universal_developer_input_button_bar).finish());
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToTop) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_bottom(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(vim_state) = vim_state.as_ref() {
|
||||
if show_vim_status {
|
||||
add_vim_status_to_stack(
|
||||
&mut stack, vim_state, appearance, true, // use adjusted padding for UDI
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
true, // use adjusted padding for UDI
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut stack,
|
||||
&self.buy_credits_banner,
|
||||
self.is_pane_focused(app),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
// If the file tree is enabled, don't include the top margin for UDI so that the UDI is flush with the
|
||||
// file tree.
|
||||
let margin_top = if FeatureFlag::FileTree.is_enabled() && self.is_input_at_top(&model, app)
|
||||
{
|
||||
0.
|
||||
} else {
|
||||
6.
|
||||
};
|
||||
|
||||
// Wrap the stack in a container with background and border styling based on focus
|
||||
let mut container = Container::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
)
|
||||
.with_margin_bottom(6.)
|
||||
.with_margin_left(6.)
|
||||
.with_margin_right(6.)
|
||||
.with_margin_top(margin_top)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)));
|
||||
|
||||
// Apply styling based on focus state
|
||||
if self.is_pane_focused(app) {
|
||||
// Focused: show background
|
||||
container = container
|
||||
.with_background(internal_colors::fg_overlay_1(theme))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()));
|
||||
} else {
|
||||
// Unfocused: no background
|
||||
container = container.with_border(Border::all(1.).with_border_fill(theme.outline()));
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
container.finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if input_mode.is_pinned_to_top() {
|
||||
column.add_child(input);
|
||||
column.add_child(ChildView::new(&self.agent_status_view).finish());
|
||||
} else {
|
||||
column.add_child(ChildView::new(&self.agent_status_view).finish());
|
||||
column.add_child(input);
|
||||
}
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Data source for the user query menu.
|
||||
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::user_query::search_item::UserQuerySearchItem;
|
||||
|
||||
/// Action emitted when a query is selected in the user query menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SelectUserQuery {
|
||||
pub exchange_id: AIAgentExchangeId,
|
||||
}
|
||||
|
||||
pub struct UserQueryDataSource {
|
||||
conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
impl UserQueryDataSource {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self { conversation_id }
|
||||
}
|
||||
|
||||
pub fn set_conversation_id(&mut self, conversation_id: AIConversationId) {
|
||||
self.conversation_id = conversation_id;
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for UserQueryDataSource {
|
||||
type Action = SelectUserQuery;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(&self.conversation_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let search_query = query.text.trim().to_lowercase();
|
||||
if search_query.is_empty() {
|
||||
// With no search, we just return all queries in chronological order (oldest first).
|
||||
let results: Vec<QueryResult<Self::Action>> = conversation
|
||||
.root_task_exchanges()
|
||||
.filter(|exchange| exchange.has_user_query())
|
||||
.map(|exchange| {
|
||||
let query_text = exchange.format_input_for_copy();
|
||||
QueryResult::from(UserQuerySearchItem::new(exchange.id, query_text))
|
||||
})
|
||||
.collect();
|
||||
Ok(results)
|
||||
} else {
|
||||
// Filter by fuzzy matching and sort by score.
|
||||
let results = conversation
|
||||
.root_task_exchanges()
|
||||
.filter(|exchange| exchange.has_user_query())
|
||||
.filter_map(|exchange| {
|
||||
let query_text = exchange.format_input_for_copy();
|
||||
let match_result =
|
||||
fuzzy_match::match_indices_case_insensitive(&query_text, &search_query)?;
|
||||
|
||||
Some(QueryResult::from(
|
||||
UserQuerySearchItem::new(exchange.id, query_text)
|
||||
.with_query_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.sorted_by(|a, b| b.score().cmp(&a.score()))
|
||||
.collect();
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for UserQueryDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Inline menu for selecting user queries from a conversation.
|
||||
//! Used by the `/fork-from` slash command to let users select which query to fork from.
|
||||
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::SelectUserQuery;
|
||||
pub use view::{UserQueryMenuEvent, UserQueryMenuView};
|
||||
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::platform::OperatingSystem;
|
||||
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
impl InlineMenuAction for SelectUserQuery {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::UserQueryMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let InlineMenuMessageArgs {
|
||||
inline_menu_model, ..
|
||||
} = args;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(item) = inline_menu_model.selected_item() {
|
||||
let exchange_id = item.exchange_id;
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" current pane"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: SelectUserQuery { exchange_id },
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept.clone(),
|
||||
));
|
||||
|
||||
let modifier_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(modifier_keystroke),
|
||||
MessageItem::text(" new pane"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: SelectUserQuery { exchange_id },
|
||||
cmd_or_ctrl_enter: true,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept_secondary.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! SearchItem implementation for user query menu items.
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles::{
|
||||
font_size, icon_color, item_background, menu_background_color, primary_text_color, ICON_MARGIN,
|
||||
ITEM_CORNER_RADIUS, ITEM_HORIZONTAL_PADDING,
|
||||
};
|
||||
use crate::terminal::input::user_query::data_source::SelectUserQuery;
|
||||
|
||||
const ICON_PADDING: f32 = 4.;
|
||||
|
||||
/// Search item for rendering a user query in the user query menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserQuerySearchItem {
|
||||
exchange_id: AIAgentExchangeId,
|
||||
query_text: String,
|
||||
query_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl UserQuerySearchItem {
|
||||
pub fn new(exchange_id: AIAgentExchangeId, query_text: String) -> Self {
|
||||
Self {
|
||||
exchange_id,
|
||||
query_text,
|
||||
query_match_result: None,
|
||||
score: OrderedFloat(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_query_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.query_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for UserQuerySearchItem {
|
||||
type Action = SelectUserQuery;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let color = icon_color(appearance);
|
||||
|
||||
// Once this search item is used by multiple sources,
|
||||
// we'll want to set this icon based on which source is generating this item.
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(Icon::ArrowSplit.to_warpui_icon(color).finish())
|
||||
.with_width(appearance.monospace_font_size())
|
||||
.with_height(appearance.monospace_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(ICON_PADDING)
|
||||
.with_background(coloru_with_opacity(color.into(), 10))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ITEM_CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Container::new(icon).with_margin_right(ICON_MARGIN).finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let background = menu_background_color(app);
|
||||
|
||||
let mut query_text = Text::new_inline(
|
||||
self.query_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size(appearance),
|
||||
)
|
||||
.with_color(primary_text_color(theme, background.into()).into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(match_result) = &self.query_match_result {
|
||||
if !match_result.matched_indices.is_empty() {
|
||||
query_text = query_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_result.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Container::new(Shrinkable::new(1., query_text.finish()).finish())
|
||||
.with_padding_right(ITEM_HORIZONTAL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
SelectUserQuery {
|
||||
exchange_id: self.exchange_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Query: {}", self.query_text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! View for the user query menu.
|
||||
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::user_query::data_source::{SelectUserQuery, UserQueryDataSource};
|
||||
|
||||
/// Events emitted by UserQueryMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UserQueryMenuEvent {
|
||||
/// User navigated to a query (arrow keys).
|
||||
SelectedQuery { exchange_id: AIAgentExchangeId },
|
||||
/// User accepted a query (hit enter).
|
||||
AcceptedQuery {
|
||||
exchange_id: AIAgentExchangeId,
|
||||
cmd_enter: bool,
|
||||
},
|
||||
/// User dismissed the menu (escape or click).
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct UserQueryMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<SelectUserQuery>>,
|
||||
data_source: ModelHandle<UserQueryDataSource>,
|
||||
mixer: ModelHandle<SearchMixer<SelectUserQuery>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
}
|
||||
|
||||
impl UserQueryMenuView {
|
||||
pub fn new(
|
||||
conversation_id: AIConversationId,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| UserQueryDataSource::new(conversation_id));
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<SelectUserQuery>::new();
|
||||
mixer.add_sync_source(data_source.clone(), []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item,
|
||||
cmd_or_ctrl_shift_enter,
|
||||
} => {
|
||||
ctx.emit(UserQueryMenuEvent::AcceptedQuery {
|
||||
exchange_id: item.exchange_id,
|
||||
cmd_enter: *cmd_or_ctrl_shift_enter,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { item } => {
|
||||
ctx.emit(UserQueryMenuEvent::SelectedQuery {
|
||||
exchange_id: item.exchange_id,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(UserQueryMenuEvent::Dismissed);
|
||||
}
|
||||
InlineMenuEvent::NoResults | InlineMenuEvent::TabChanged => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if let Some(conversation_id) = input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.user_query_conversation_id()
|
||||
{
|
||||
me.data_source.update(ctx, |ds, _| {
|
||||
ds.set_conversation_id(conversation_id);
|
||||
});
|
||||
me.refresh_results("", ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_user_query_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
// The buffer is cleared when the menu opens, so the entire content is the search query
|
||||
me.refresh_results(new_content, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
data_source,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&self, search_query: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: search_query.to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, cmd_enter: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(cmd_enter, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for UserQueryMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"UserQueryMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for UserQueryMenuView {
|
||||
type Event = UserQueryMenuEvent;
|
||||
}
|
||||
Reference in New Issue
Block a user