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
@@ -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;
}