Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/search/data_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,10 @@ impl<T: Action + Clone> QueryResult<T> {
self.item.accessibility_help_message()
}

pub fn detail_data(&self) -> Option<crate::search::item::SearchItemDetail> {
self.item.detail_data()
}

/// Returns an optional deduplication key for this item from the [`SearchItem`].
pub fn dedup_key(&self) -> Option<String> {
self.item.dedup_key()
Expand Down
12 changes: 12 additions & 0 deletions app/src/search/item.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
use ordered_float::OrderedFloat;
use warp_core::ui::theme::Fill;
use warpui::fonts::FamilyId;
use warpui::{Action, AppContext, Element};

use crate::appearance::Appearance;

use super::result_renderer::ItemHighlightState;

#[derive(Clone)]
pub struct SearchItemDetail {
pub title: String,
pub description: Option<String>,
pub title_font_family: FamilyId,
}

/// Location where icon should be rendered relative to the [`SearchItem`].
pub enum IconLocation {
/// Icon should be centered within the element.
Expand Down Expand Up @@ -109,4 +117,8 @@ pub trait SearchItem: Send + Sync {
fn tooltip(&self) -> Option<String> {
None
}

fn detail_data(&self) -> Option<SearchItemDetail> {
None
}
}
234 changes: 200 additions & 34 deletions app/src/terminal/input/slash_commands/cloud_mode_v2_view.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;

use pathfinder_geometry::vector::vec2f;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::elements::{
Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler, Flex, Hoverable,
MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, SavePosition,
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Text,
Border, ChildAnchor, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, DropShadow, EventHandler,
Flex, Hoverable, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning,
ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition,
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack, Text,
};
use warpui::platform::Cursor;
use warpui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};

use crate::search::data_source::QueryFilter;
use crate::search::item::SearchItemDetail;
use crate::search::mixer::{AddAsyncSourceOptions, SearchMixer, SearchMixerEvent};
use crate::search::result_renderer::{QueryResultRenderer, QueryResultRendererStyles};
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
Expand Down Expand Up @@ -55,10 +58,40 @@ const DIVIDER_HEIGHT: f32 = 1.;

const DIVIDER_VERTICAL_PADDING: f32 = 0.;

const SIDECAR_WIDTH: f32 = MENU_WIDTH;

const SIDECAR_MAX_HEIGHT: f32 = 240.;

const SIDECAR_GAP: f32 = 2.;

const SIDECAR_DESCRIPTION_FONT_SIZE: f32 = 12.;

const SIDECAR_TITLE_TO_DESCRIPTION_GAP: f32 = 4.;

const NAME_DESCRIPTION_GAP_PX: f32 = 8.;

fn row_position_id(visible_idx: usize) -> String {
format!("cloud_mode_v2_slash_row_{visible_idx}")
}

fn item_is_truncated_in_row(detail: &SearchItemDetail, app: &AppContext) -> bool {
let appearance = Appearance::as_ref(app);
let font_size = inline_styles::font_size(appearance);
let font_cache = app.font_cache();
let name_em = font_cache.em_width(detail.title_font_family, font_size);
let name_px = name_em * detail.title.chars().count() as f32;
let row_chrome_px = MENU_HORIZONTAL_PADDING * 2. + ICON_SIZE + inline_styles::ICON_MARGIN;
let available = MENU_WIDTH - row_chrome_px;
match &detail.description {
Some(description) => {
let description_em = font_cache.em_width(appearance.ui_font_family(), font_size);
let description_px = description_em * description.chars().count() as f32;
(name_px + NAME_DESCRIPTION_GAP_PX + description_px) > available
Comment on lines +85 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This truncation check does not match the actual row layout: rows with descriptions reserve a fixed name column in InlineItem::render_item, so a short title plus long description can still truncate even when this sum fits. Use the same name-column width when computing description availability.

}
None => name_px > available,
}
}

static QUERY_RESULT_RENDERER_STYLES: LazyLock<QueryResultRendererStyles> =
LazyLock::new(|| QueryResultRendererStyles {
result_item_height_fn: |appearance| appearance.monospace_font_size() + 8.,
Expand Down Expand Up @@ -822,46 +855,121 @@ impl CloudModeV2SlashCommandView {
.with_vertical_padding(ROW_VERTICAL_PADDING)
.finish()
}
}

impl Entity for CloudModeV2SlashCommandView {
type Event = SlashCommandsEvent;
}

impl TypedActionView for CloudModeV2SlashCommandView {
type Action = CloudModeV2SlashCommandAction;

fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CloudModeV2SlashCommandAction::Accept {
item,
cmd_or_ctrl_enter,
fn selected_detail_data(&self) -> Option<SearchItemDetail> {
match &self.menu_state {
MenuState::NoSearchActive {
sections,
expanded_sections,
selected_idx,
..
} => {
self.emit_selection(item, *cmd_or_ctrl_enter, ctx);
}
CloudModeV2SlashCommandAction::HoverIdx(idx) => {
let idx = *idx;
match &self.menu_state {
MenuState::NoSearchActive { .. } => self.set_browsing_selection(idx, ctx),
MenuState::SearchActive { .. } => self.set_search_selection(idx, ctx),
let idx = (*selected_idx)?;
let row = browsing_rows(sections, expanded_sections)
.get(idx)
.copied()?;
match row {
NoSearchActiveRow::Item { section, item_idx } => {
let rendered = sections.iter().find(|s| s.section == section)?;
let renderer = rendered.items.get(item_idx)?;
renderer.search_result.detail_data()
}
_ => None,
}
}
CloudModeV2SlashCommandAction::ToggleSection(section) => {
self.toggle_section(*section, ctx);
}
CloudModeV2SlashCommandAction::Dismiss => {
self.dismiss(ctx);
MenuState::SearchActive {
results,
selected_idx,
} => {
let idx = (*selected_idx)?;
let renderer = results.get(idx)?;
renderer.search_result.detail_data()
}
}
}
}

impl View for CloudModeV2SlashCommandView {
fn ui_name() -> &'static str {
"CloudModeV2SlashCommandView"
fn selected_visible_idx(&self) -> Option<usize> {
match &self.menu_state {
MenuState::NoSearchActive { selected_idx, .. } => *selected_idx,
MenuState::SearchActive { selected_idx, .. } => *selected_idx,
}
}

fn render(&self, app: &AppContext) -> Box<dyn Element> {
fn render_sidecar_if_eligible(&self, app: &AppContext) -> Option<(String, Box<dyn Element>)> {
let detail = self.selected_detail_data()?;
if !item_is_truncated_in_row(&detail, app) {
return None;
}
let visible_idx = self.selected_visible_idx()?;
Some((
row_position_id(visible_idx),
self.render_sidecar_panel(&detail, app),
))
}

fn render_sidecar_panel(
&self,
detail: &SearchItemDetail,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let menu_bg = inline_styles::menu_background_color(app);
let primary = inline_styles::primary_text_color(theme, menu_bg.into());
let secondary = inline_styles::secondary_text_color(theme, menu_bg.into());

let title = Text::new_inline(
detail.title.clone(),
detail.title_font_family,
inline_styles::font_size(appearance),
)
.with_color(primary.into())
.finish();

let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Min)
.with_child(title);

if let Some(description_text) = detail.description.clone() {
let description = Text::new(
description_text,
appearance.ui_font_family(),
SIDECAR_DESCRIPTION_FONT_SIZE,
)
.with_color(secondary.into())
.finish();
column = column.with_child(
Container::new(description)
.with_margin_top(SIDECAR_TITLE_TO_DESCRIPTION_GAP)
.finish(),
);
}

Container::new(
ConstrainedBox::new(
Clipped::new(
Container::new(column.finish())
.with_horizontal_padding(MENU_HORIZONTAL_PADDING)
.with_vertical_padding(ROW_VERTICAL_PADDING)
.finish(),
)
.finish(),
)
.with_max_width(SIDECAR_WIDTH)
.with_max_height(SIDECAR_MAX_HEIGHT)
.finish(),
)
.with_background(Fill::Solid(menu_bg))
.with_border(Border::all(1.).with_border_fill(Fill::Solid(theme.outline().into_solid())))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS)))
.with_padding_top(MENU_VERTICAL_PADDING)
.with_padding_bottom(MENU_VERTICAL_PADDING)
.with_drop_shadow(DropShadow::default())
.finish()
}

fn render_menu_panel(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let menu_bg = inline_styles::menu_background_color(app);
Expand Down Expand Up @@ -925,6 +1033,64 @@ impl View for CloudModeV2SlashCommandView {
}
}

impl Entity for CloudModeV2SlashCommandView {
type Event = SlashCommandsEvent;
}

impl TypedActionView for CloudModeV2SlashCommandView {
type Action = CloudModeV2SlashCommandAction;

fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CloudModeV2SlashCommandAction::Accept {
item,
cmd_or_ctrl_enter,
} => {
self.emit_selection(item, *cmd_or_ctrl_enter, ctx);
}
CloudModeV2SlashCommandAction::HoverIdx(idx) => {
let idx = *idx;
match &self.menu_state {
MenuState::NoSearchActive { .. } => self.set_browsing_selection(idx, ctx),
MenuState::SearchActive { .. } => self.set_search_selection(idx, ctx),
}
}
CloudModeV2SlashCommandAction::ToggleSection(section) => {
self.toggle_section(*section, ctx);
}
CloudModeV2SlashCommandAction::Dismiss => {
self.dismiss(ctx);
}
}
}
}

impl View for CloudModeV2SlashCommandView {
fn ui_name() -> &'static str {
"CloudModeV2SlashCommandView"
}

fn render(&self, app: &AppContext) -> Box<dyn Element> {
let menu_panel = self.render_menu_panel(app);
let Some((row_position_id, sidecar)) = self.render_sidecar_if_eligible(app) else {
return menu_panel;
};
let mut stack = Stack::new();
stack.add_child(menu_panel);
stack.add_positioned_overlay_child(
sidecar,
OffsetPositioning::offset_from_save_position_element(
row_position_id,
vec2f(SIDECAR_GAP, 0.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomRight,
ChildAnchor::BottomLeft,
),
);
stack.finish()
}
}

fn render_section_header(section: Section, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Expand Down
9 changes: 9 additions & 0 deletions app/src/terminal/input/slash_commands/search_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use warpui::prelude::{ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex
use warpui::{AppContext, Element, SingletonEntity};

use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
use crate::search::item::SearchItemDetail;
use crate::search::slash_command_menu::static_commands::commands::COMMAND_REGISTRY;
use crate::search::{ItemHighlightState, SearchItem};
use crate::terminal::input::inline_menu::styles as inline_styles;
Expand Down Expand Up @@ -177,4 +178,12 @@ impl SearchItem for InlineItem {
fn accessibility_label(&self) -> String {
format!("{:?}", self.action)
}

fn detail_data(&self) -> Option<SearchItemDetail> {
Some(SearchItemDetail {
title: self.name.clone(),
description: self.description.clone(),
title_font_family: self.font_family,
})
}
}