Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+610
View File
@@ -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)
}
}
+63
View File
@@ -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;
}
+413
View File
@@ -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()
}
}
+196
View File
@@ -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()
}
}
+543
View File
@@ -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 = ();
}
+101
View File
@@ -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;
}
+389
View File
@@ -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;
+101
View File
@@ -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()
},
)
}
}
+98
View File
@@ -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,
}
}
}
+153
View File
@@ -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.;
}
+362
View File
@@ -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
}
+6
View File
@@ -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()
}
+498
View File
@@ -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 = ();
}
+58
View File
@@ -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))
}
}
+165
View File
@@ -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)
}
}
+151
View File
@@ -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 = ();
}
+6
View File
@@ -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(),
}
}
}
+220
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
mod data_source;
mod view;
pub use data_source::*;
pub use view::*;
+129
View File
@@ -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 = ();
}
+75
View File
@@ -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))
}
}
+196
View File
@@ -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)
}
}
+155
View File
@@ -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 = ();
}
+33
View File
@@ -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)
}
}
}
+148
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
mod data_source;
mod view;
pub use data_source::{AcceptSkill, SkillSelectorDataSource, UpdatedAvailableSkills};
pub use view::{InlineSkillSelectorEvent, InlineSkillSelectorView};
+166
View File
@@ -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>,
},
}
+307
View File
@@ -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
}
+226
View File
@@ -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 = ();
}
+82
View File
@@ -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)
}
}
+158
View File
@@ -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;
}