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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,498 @@
use super::{
ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect,
AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState,
};
use ai::agent::{
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
action_result::AskUserQuestionAnswerItem,
};
fn build_question(
question_id: &str,
question: &str,
is_multiselect: bool,
supports_other: bool,
options: &[&str],
) -> AskUserQuestionItem {
AskUserQuestionItem {
question_id: question_id.to_string(),
question: question.to_string(),
question_type: AskUserQuestionType::MultipleChoice {
is_multiselect,
options: options
.iter()
.map(|label| AskUserQuestionOption {
label: (*label).to_string(),
recommended: false,
})
.collect(),
supports_other,
},
}
}
fn build_session(questions: Vec<AskUserQuestionItem>) -> AskUserQuestionSession {
AskUserQuestionSession::new(questions)
}
fn view_state_for(session: &AskUserQuestionSession) -> AskUserQuestionViewState {
ask_user_question_view_state(session.current())
}
fn current_draft(session: &AskUserQuestionSession) -> Option<&super::QuestionDraft> {
session.current().and_then(|current| current.draft)
}
#[test]
fn enter_on_other_row_focuses_the_other_input() {
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: Some(1),
active_other_text: None,
}),
AskUserQuestionEffect::FocusOtherInput
);
}
#[test]
fn enter_without_an_answer_submits_the_last_question_immediately() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
false,
true,
&["Stable", "Nightly"],
)]);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: None,
active_other_text: None,
}),
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}])
);
}
#[test]
fn enter_after_a_selected_answer_schedules_auto_advance() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
false,
false,
&["Stable", "Nightly"],
)]);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: None,
active_other_text: None,
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
}
#[test]
fn enter_with_blank_other_input_submits_the_last_question_immediately() {
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: None,
active_other_text: None,
}),
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
}])
);
}
#[test]
fn enter_with_active_other_text_schedules_auto_advance() {
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: None,
active_other_text: Some("nightly".to_string()),
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert_eq!(
current_draft(&session).and_then(|draft| draft.other_text.as_deref()),
Some("nightly")
);
}
#[test]
fn enter_on_single_select_option_toggles_it_and_schedules_auto_advance() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
false,
true,
&["Stable", "Nightly"],
)]);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: Some(1),
active_other_text: None,
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
}
#[test]
fn enter_on_non_last_multi_select_option_toggles_it_and_schedules_auto_advance() {
let mut session = build_session(vec![
build_question("q1", "First", true, true, &["Stable", "Nightly"]),
build_question("q2", "Second", false, false, &["CLI"]),
]);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: Some(1),
active_other_text: None,
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
}
#[test]
fn enter_on_answered_non_last_multi_select_option_keeps_existing_selection_and_advances() {
let mut session = build_session(vec![
build_question("q1", "First", true, true, &["Stable", "Nightly"]),
build_question("q2", "Second", false, false, &["CLI"]),
]);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
AskUserQuestionEffect::RefreshCurrent
);
assert_eq!(
session.apply(AskUserQuestionAction::PressEnter {
highlighted_index: Some(1),
active_other_text: None,
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert!(current_draft(&session).is_some_and(|draft| {
draft.selected_option_indices.contains(&0) && draft.selected_option_indices.contains(&1)
}));
}
#[test]
fn single_select_non_last_toggle_schedules_auto_advance() {
let mut session = build_session(vec![
build_question("q1", "First", false, false, &["Rust", "Go"]),
build_question("q2", "Second", false, false, &["CLI", "GUI"]),
]);
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 });
assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance);
assert_eq!(session.current_question_index(), 0);
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
}
#[test]
fn multi_select_non_last_toggle_does_not_auto_advance() {
let mut session = build_session(vec![
build_question("q1", "First", true, false, &["Rust", "Go"]),
build_question("q2", "Second", false, false, &["CLI", "GUI"]),
]);
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 });
assert_eq!(effect, AskUserQuestionEffect::RefreshCurrent);
assert_eq!(session.current_question_index(), 0);
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1)));
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
}
#[test]
fn last_multi_select_toggle_schedules_auto_advance() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
true,
false,
&["Rust", "Go"],
)]);
let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 });
assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance);
assert_eq!(session.current_question_index(), 0);
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0)));
}
#[test]
fn single_select_clicking_selected_option_clears_the_draft() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
false,
false,
&["Rust", "Go"],
)]);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
AskUserQuestionEffect::RefreshCurrent
);
assert!(current_draft(&session).is_none());
assert_eq!(session.current_question_index(), 0);
}
#[test]
fn drafts_survive_navigation_and_submit_skips_only_unanswered_questions() {
let mut session = build_session(vec![
build_question("q1", "First", true, false, &["Rust", "Go"]),
build_question("q2", "Second", true, false, &["CLI", "GUI"]),
build_question("q3", "Third", false, true, &["Stable"]),
]);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
AskUserQuestionEffect::RefreshCurrent
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigateNext),
AskUserQuestionEffect::ShowQuestion
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigatePrev),
AskUserQuestionEffect::ShowQuestion
);
assert_eq!(
current_draft(&session).map(|draft| draft.selected_option_indices.len()),
Some(1)
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigateNext),
AskUserQuestionEffect::ShowQuestion
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigateNext),
AskUserQuestionEffect::ShowQuestion
);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert_eq!(
session.apply(AskUserQuestionAction::SaveOtherText {
text: Some("nightly toolchain".to_string()),
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
let effect = session.apply(AskUserQuestionAction::Confirm);
assert_eq!(
effect,
AskUserQuestionEffect::Submit(vec![
AskUserQuestionAnswerItem::Answered {
question_id: "q1".to_string(),
selected_options: vec!["Rust".to_string()],
other_text: String::new(),
},
AskUserQuestionAnswerItem::Skipped {
question_id: "q2".to_string(),
},
AskUserQuestionAnswerItem::Answered {
question_id: "q3".to_string(),
selected_options: vec![],
other_text: "nightly toolchain".to_string(),
},
])
);
assert!(matches!(
session.phase(),
AskUserQuestionPhase::Completed { .. }
));
}
#[test]
fn multi_select_other_text_does_not_auto_advance_before_last_question() {
let mut session = build_session(vec![
build_question("q1", "First", true, true, &["Rust"]),
build_question("q2", "Second", false, false, &["CLI"]),
]);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert_eq!(
session.apply(AskUserQuestionAction::SaveOtherText {
text: Some("nightly".to_string()),
}),
AskUserQuestionEffect::RefreshCurrent
);
assert_eq!(session.current_question_index(), 0);
assert_eq!(
current_draft(&session).and_then(|draft| draft.other_text.as_deref()),
Some("nightly")
);
}
#[test]
fn skip_all_moves_session_to_completed_with_skipped_answers() {
let mut session = build_session(vec![
build_question("q1", "First", true, false, &["Rust"]),
build_question("q2", "Second", false, true, &["Stable"]),
]);
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 });
session.apply(AskUserQuestionAction::NavigateNext);
session.apply(AskUserQuestionAction::OpenOtherInput);
session.apply(AskUserQuestionAction::SaveOtherText {
text: Some("nightly".to_string()),
});
let effect = session.apply(AskUserQuestionAction::SkipAll);
assert_eq!(
effect,
AskUserQuestionEffect::Submit(vec![
AskUserQuestionAnswerItem::Skipped {
question_id: "q1".to_string(),
},
AskUserQuestionAnswerItem::Skipped {
question_id: "q2".to_string(),
},
])
);
assert!(matches!(
session.phase(),
AskUserQuestionPhase::Completed { .. }
));
}
#[test]
fn other_text_submission_exits_input_and_submits_last_question() {
let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert!(view_state_for(&session).show_other_input);
assert_eq!(
session.apply(AskUserQuestionAction::SaveOtherText {
text: Some("nightly".to_string()),
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
let draft = current_draft(&session).expect("draft should exist");
assert_eq!(draft.other_text.as_deref(), Some("nightly"));
assert!(!draft.is_other_input_active);
assert!(!view_state_for(&session).show_other_input);
let effect = session.apply(AskUserQuestionAction::Confirm);
assert_eq!(
effect,
AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Answered {
question_id: "q1".to_string(),
selected_options: vec![],
other_text: "nightly".to_string(),
}])
);
}
#[test]
fn navigating_next_on_last_question_is_a_noop() {
let mut session = build_session(vec![build_question(
"q1",
"Only",
false,
false,
&["Rust", "Go"],
)]);
assert_eq!(
session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigateNext),
AskUserQuestionEffect::Noop
);
assert!(matches!(session.phase(), AskUserQuestionPhase::Editing));
assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0)));
}
#[test]
fn view_state_shows_other_input() {
let mut session = build_session(vec![
build_question("q1", "First", false, true, &["Stable"]),
build_question("q2", "Second", false, false, &["CLI"]),
]);
assert_eq!(
view_state_for(&session),
AskUserQuestionViewState {
show_other_input: false,
}
);
assert_eq!(
session.apply(AskUserQuestionAction::OpenOtherInput),
AskUserQuestionEffect::FocusOtherInput
);
assert_eq!(
view_state_for(&session),
AskUserQuestionViewState {
show_other_input: true,
}
);
assert_eq!(
session.apply(AskUserQuestionAction::SaveOtherText {
text: Some("nightly".to_string()),
}),
AskUserQuestionEffect::ScheduleAutoAdvance
);
assert_eq!(
session.apply(AskUserQuestionAction::NavigateNext),
AskUserQuestionEffect::ShowQuestion
);
assert_eq!(
view_state_for(&session),
AskUserQuestionViewState {
show_other_input: false,
}
);
}
@@ -0,0 +1,290 @@
use settings::Setting as _;
use warp_core::ui::Icon;
use warpui::elements::{
ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Text,
};
use warpui::ui_components::components::UiComponent;
use warpui::{
AppContext, Element, Entity, EventContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::report_if_error;
use crate::Appearance;
use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::ui_components::blended_colors;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme};
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::view_util::error_color;
#[derive(Clone, Debug)]
pub enum AwsBedrockCredentialsErrorAction {
RunLoginCommand,
Configure,
ToggleAutoLogin,
}
#[derive(Clone, Debug)]
pub enum AwsBedrockCredentialsErrorEvent {
RunLoginCommand,
ConfigureLoginCommand,
}
pub struct AwsBedrockCredentialsErrorView {
model_name: String,
login_command: String,
/// Whether auto-login was triggered, which shows a simpler "running command" message.
auto_login_triggered: bool,
// Run button
run_button: ViewHandle<ActionButton>,
// Configure button
configure_button: ViewHandle<ActionButton>,
// Auto-login checkbox
auto_login_checkbox_handle: MouseStateHandle,
}
impl AwsBedrockCredentialsErrorView {
pub fn new(
model_name: String,
login_command: String,
auto_login_triggered: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
// Run button
let run_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Refresh AWS Credentials", PrimaryTheme)
.with_size(ButtonSize::InlineActionHeader)
.on_click(|ctx| {
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::RunLoginCommand)
})
});
// Configure button
let configure_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Configure", NakedTheme)
.with_size(ButtonSize::InlineActionHeader)
.on_click(|ctx| {
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::Configure)
})
});
// Subscribe to AISettings changes to update checkbox state
ctx.subscribe_to_model(&AISettings::handle(ctx), |_me, _, event, ctx| {
if matches!(event, AISettingsChangedEvent::AwsBedrockAutoLogin { .. }) {
ctx.notify();
}
});
Self {
model_name,
login_command,
auto_login_triggered,
run_button,
configure_button,
auto_login_checkbox_handle: MouseStateHandle::default(),
}
}
}
impl Entity for AwsBedrockCredentialsErrorView {
type Event = AwsBedrockCredentialsErrorEvent;
}
impl View for AwsBedrockCredentialsErrorView {
fn ui_name() -> &'static str {
"AwsBedrockCredentialsErrorView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// If auto-login was triggered, show a simple "running command" message
if self.auto_login_triggered {
return Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(
format!("Running `{}`...", self.login_command),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish(),
)
.finish();
}
let auto_login_enabled = *AISettings::as_ref(app).aws_bedrock_auto_login.value();
// Helper closures to create elements (since Box<dyn Element> can't be cloned)
let make_alert_icon = || {
ConstrainedBox::new(
Icon::AlertTriangle
.to_warpui_icon(error_color(theme).into())
.finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish()
};
let make_alert_text = || {
Text::new(
"AWS credentials expired or missing",
appearance.ui_font_family(),
14.,
)
.with_color(error_color(theme))
.with_selectable(false)
.finish()
};
let make_detail_text = || {
Text::new(
format!(
"Failed to authenticate with AWS Bedrock when using {}. \
Run `{}` to refresh credentials.",
self.model_name, self.login_command
),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish()
};
let make_buttons_row = || {
Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&self.configure_button).finish())
.with_child(ChildView::new(&self.run_button).finish())
.finish()
};
let make_checkbox_row = || {
let checkbox = Container::new(
appearance
.ui_builder()
.checkbox(self.auto_login_checkbox_handle.clone(), None)
.check(auto_login_enabled)
.build()
.on_click(|ctx: &mut EventContext, _, _| {
ctx.dispatch_typed_action(AwsBedrockCredentialsErrorAction::ToggleAutoLogin)
})
.finish(),
)
.with_margin_left(-4.)
.finish();
let checkbox_label = Text::new(
"Always run automatically",
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(Container::new(checkbox_label).with_margin_left(4.).finish())
.finish()
};
let make_header_row = || {
Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(make_alert_icon())
.with_child(make_alert_text())
.finish()
};
// Wide layout: detail text on left, checkbox + buttons on right (same row)
let wide_layout = Flex::column()
.with_spacing(12.)
.with_child(make_header_row())
.with_child(
Flex::row()
.with_spacing(8.)
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., make_detail_text()).finish())
.with_child(
Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(make_checkbox_row())
.with_child(make_buttons_row())
.finish(),
)
.finish(),
)
.finish();
// Narrow layout: detail text, then checkbox + buttons row
let narrow_layout = Flex::column()
.with_spacing(12.)
.with_child(make_header_row())
.with_child(make_detail_text())
.with_child(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(make_checkbox_row())
.with_child(make_buttons_row())
.finish(),
)
.finish();
// Switch threshold - when width is less than this, use narrow layout
let threshold = 600.0 * appearance.monospace_ui_scalar();
SizeConstraintSwitch::new(
wide_layout,
vec![(
SizeConstraintCondition::WidthLessThan(threshold),
narrow_layout,
)],
)
.finish()
}
}
impl TypedActionView for AwsBedrockCredentialsErrorView {
type Action = AwsBedrockCredentialsErrorAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AwsBedrockCredentialsErrorAction::RunLoginCommand => {
ctx.emit(AwsBedrockCredentialsErrorEvent::RunLoginCommand);
}
AwsBedrockCredentialsErrorAction::Configure => {
ctx.emit(AwsBedrockCredentialsErrorEvent::ConfigureLoginCommand);
}
AwsBedrockCredentialsErrorAction::ToggleAutoLogin => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let current = *settings.aws_bedrock_auto_login.value();
report_if_error!(settings.aws_bedrock_auto_login.set_value(!current, ctx));
});
ctx.notify();
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
use warpui::SingletonEntity;
use warpui::{
elements::{
Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex,
Hoverable, Icon, MouseStateHandle, ParentElement, Radius, Text,
},
platform::Cursor,
AppContext, Element,
};
use warp_core::ui::appearance::Appearance;
use warp_core::ui::icons::Icon as CoreIcon;
use warp_core::ui::theme::color::internal_colors::neutral_2;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ai::blocklist::inline_action::inline_action_header::{
self, INLINE_ACTION_HORIZONTAL_PADDING, INLINE_ACTION_VERTICAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion};
pub struct CreateOrEditDocumentAction {
document_id: AIDocumentId,
document_title: String,
document_version: AIDocumentVersion,
mouse_state: MouseStateHandle,
}
impl CreateOrEditDocumentAction {
pub fn new(
document_id: AIDocumentId,
document_version: AIDocumentVersion,
mouse_state: MouseStateHandle,
app: &AppContext,
) -> Option<Self> {
let document = AIDocumentModel::as_ref(app).get_document(&document_id, document_version)?;
Some(Self {
document_id,
document_title: document.get_title(),
document_version: document.get_version(),
mouse_state,
})
}
pub fn render(self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let subtext_color = theme.sub_text_color(theme.background()).into_solid();
let main_text_color = theme.main_text_color(theme.background()).into_solid();
let file_icon = Container::new(
ConstrainedBox::new(Icon::new(CoreIcon::Compass.into(), main_text_color).finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish(),
)
.with_margin_right(inline_action_header::ICON_MARGIN)
.finish();
let title_text = Clipped::new(
Text::new_inline(
self.document_title.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(main_text_color)
.with_selectable(false)
.finish(),
)
.finish();
let version_text = Container::new(
Text::new_inline(
self.document_version.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(subtext_color)
.with_selectable(false)
.finish(),
)
.with_margin_right(inline_action_header::ICON_MARGIN)
.finish();
let view_document_icon =
ConstrainedBox::new(Icon::new(CoreIcon::Share3.into(), main_text_color).finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
let view_document_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(0.)
.with_child(version_text)
.with_child(view_document_icon)
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(file_icon)
.with_child(Expanded::new(1., title_text).finish())
.with_child(view_document_row);
let content = Container::new(row.finish())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_VERTICAL_PADDING)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background_color(neutral_2(theme))
.with_border(warpui::elements::Border::all(1.).with_border_fill(theme.surface_2()))
.finish();
Hoverable::new(self.mouse_state, move |_mouse_state| content)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _app, _| {
ctx.dispatch_typed_action(crate::WorkspaceAction::OpenAIDocumentPane {
document_id: self.document_id,
document_version: self.document_version,
});
})
.finish()
.with_agent_output_item_spacing(app)
.finish()
}
}
@@ -0,0 +1,406 @@
use pathfinder_color::ColorU;
use std::borrow::Cow;
use std::rc::Rc;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
EventHandler, Expanded, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Text,
},
fonts::FamilyId,
platform::Cursor,
AppContext, Element, EventContext, SingletonEntity,
};
use crate::{
ai::blocklist::inline_action::inline_action_icons::icon_size,
ui_components::blended_colors,
view_components::compactible_action_button::{
render_compact_and_regular_button_rows, render_expansion_icon,
RenderCompactibleActionButton,
},
};
/// Same padding constants as the original for consistency
pub const INLINE_ACTION_HORIZONTAL_PADDING: f32 = 16.;
/// The vertical padding applied to the requested action row's content body (usually a command).
pub const INLINE_ACTION_VERTICAL_PADDING: f32 = 12.;
pub const INLINE_ACTION_HEADER_VERTICAL_PADDING: f32 = 10.;
pub const ICON_MARGIN: f32 = 8.;
pub type OnToggleExpandedCallback = Rc<dyn Fn(&mut EventContext) + 'static>;
pub type OnRightClickCallback = Rc<dyn Fn(&mut EventContext) + 'static>;
/// Configuration for manual expansion behavior
#[derive(Clone)]
pub struct ExpandedConfig {
pub is_expanded: bool,
/// Optional callback for when expansion toggle is clicked
pub on_toggle_expanded: Option<OnToggleExpandedCallback>,
/// Optional callback for when right-click is triggered
pub on_right_click: Option<OnRightClickCallback>,
/// Mouse state handle for expansion toggle
pub toggle_mouse_state: MouseStateHandle,
pub expands_upwards: bool,
}
impl ExpandedConfig {
pub fn new(is_expanded: bool, toggle_mouse_state: MouseStateHandle) -> Self {
Self {
is_expanded,
on_toggle_expanded: None,
on_right_click: None,
toggle_mouse_state,
expands_upwards: false,
}
}
pub fn with_expands_upwards(mut self) -> Self {
self.expands_upwards = true;
self
}
pub fn with_toggle_callback<F>(mut self, callback: F) -> Self
where
F: Fn(&mut EventContext) + 'static,
{
self.on_toggle_expanded = Some(Rc::new(callback));
self
}
pub fn with_right_click_callback<F>(mut self, callback: F) -> Self
where
F: Fn(&mut EventContext) + 'static,
{
self.on_right_click = Some(Rc::new(callback));
self
}
}
/// Configuration for when we want a right clickable element,
/// but that element isn't necessarily expandable.
#[derive(Clone)]
pub struct RightClickConfig {
pub on_right_click: OnRightClickCallback,
pub header_mouse_state: MouseStateHandle,
}
impl RightClickConfig {
pub fn new(on_right_click: OnRightClickCallback, header_mouse_state: MouseStateHandle) -> Self {
Self {
on_right_click,
header_mouse_state,
}
}
}
#[derive(Clone)]
pub enum InteractionMode {
/// Renders action buttons.
ActionButtons {
action_buttons: Vec<Rc<dyn RenderCompactibleActionButton>>,
size_switch_threshold: f32,
},
/// Renders expansion chevron, with caller-specified click handler.
ManuallyExpandable(ExpandedConfig),
/// Renders a right-clickable element, with caller-specified right-click handler.
RightClickable(RightClickConfig),
}
#[derive(Clone)]
pub struct HeaderConfig {
pub title: Cow<'static, str>,
pub font_family: FamilyId,
/// Whether to parse the title as markdown when rendering.
pub use_markdown: bool,
pub icon: Option<warpui::elements::Icon>,
pub badge: Option<String>,
pub interaction_mode: Option<InteractionMode>,
pub is_text_selectable: bool,
pub font_color_override: Option<ColorU>,
pub corner_radius_override: Option<CornerRadius>,
pub soft_wrap_title: bool,
}
impl HeaderConfig {
pub fn new(title: impl Into<Cow<'static, str>>, app: &AppContext) -> Self {
Self {
title: title.into(),
font_family: Appearance::as_ref(app).ui_font_family(),
use_markdown: false,
icon: None,
badge: None,
interaction_mode: None,
is_text_selectable: false,
font_color_override: None,
corner_radius_override: None,
soft_wrap_title: false,
}
}
pub fn with_soft_wrap_title(mut self) -> Self {
self.soft_wrap_title = true;
self
}
pub fn with_font_family(mut self, font: FamilyId) -> Self {
self.font_family = font;
self
}
pub fn with_icon(mut self, icon: warpui::elements::Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn with_badge(mut self, badge: String) -> Self {
self.badge = Some(badge);
self
}
pub fn with_interaction_mode(mut self, interaction_mode: InteractionMode) -> Self {
self.interaction_mode = Some(interaction_mode);
self
}
pub fn with_selectable_text(mut self) -> Self {
self.is_text_selectable = true;
self
}
pub fn with_font_color(mut self, font_color: ColorU) -> Self {
self.font_color_override = Some(font_color);
self
}
pub fn with_corner_radius_override(mut self, corner_radius: CornerRadius) -> Self {
self.corner_radius_override = Some(corner_radius);
self
}
/// Parses the title as markdown when rendering.
pub fn with_markdown(mut self) -> Self {
self.use_markdown = true;
self
}
pub fn render_header(
self,
app: &AppContext,
interaction_mode_content: Option<Box<dyn Element>>,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
let mut left_content_container = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(icon) = self.icon {
left_content_container.add_child(
Container::new(
ConstrainedBox::new(icon.finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish(),
)
.with_margin_right(ICON_MARGIN)
.finish(),
)
}
let text_color = self
.font_color_override
.unwrap_or_else(|| blended_colors::text_main(appearance.theme(), header_background));
let mut title_element = Text::new_inline(
self.title.clone(),
self.font_family,
appearance.monospace_font_size(),
)
.soft_wrap(self.soft_wrap_title)
.with_selectable(self.is_text_selectable)
.with_color(text_color)
.finish();
if self.use_markdown {
if let Ok(formatted_text) = markdown_parser::parse_markdown(&self.title) {
let mut element = FormattedTextElement::new(
formatted_text,
appearance.monospace_font_size(),
self.font_family,
appearance.monospace_font_family(),
text_color,
Default::default(),
)
.set_selectable(self.is_text_selectable);
if !self.soft_wrap_title {
element = element.with_no_text_wrapping();
}
title_element = element.finish();
}
}
left_content_container.add_child(
Expanded::new(
1.,
Container::new(title_element).with_margin_right(8.).finish(),
)
.finish(),
);
if let Some(badge) = self.badge {
left_content_container.add_child(
Container::new(
Text::new(
badge,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(internal_colors::fg_overlay_5(theme).into())
.finish(),
)
.with_horizontal_padding(8.)
.with_vertical_padding(4.)
.with_margin_right(8.)
.with_border(Border::all(1.0).with_border_fill(theme.outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.with_background(theme.surface_1())
.finish(),
);
}
header_row.add_child(Shrinkable::new(1., left_content_container.finish()).finish());
if let Some(interaction_mode_content) = interaction_mode_content {
header_row.add_child(interaction_mode_content);
}
let looks_expanded_downwards =
self.interaction_mode
.as_ref()
.is_some_and(|mode| match mode {
InteractionMode::ActionButtons { .. } => true,
InteractionMode::ManuallyExpandable(expansion_config) => {
expansion_config.is_expanded && !expansion_config.expands_upwards
}
InteractionMode::RightClickable(..) => false,
});
let container = Container::new(header_row.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(
if let Some(corner_radius_override) = self.corner_radius_override {
corner_radius_override
} else if looks_expanded_downwards {
CornerRadius::with_top(Radius::Pixels(8.))
} else {
CornerRadius::with_all(Radius::Pixels(8.))
},
)
.finish();
if let Some(InteractionMode::ManuallyExpandable(expansion_config)) = &self.interaction_mode
{
let element = if let Some(callback) = &expansion_config.on_toggle_expanded {
let callback = Rc::clone(callback);
let mouse_state = expansion_config.toggle_mouse_state.clone();
Hoverable::new(mouse_state, |_| container)
.on_click(move |ctx, _, _| {
callback(ctx);
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
container
};
// Wrap in EventHandler to allow right-click event propagation
if let Some(right_click_callback) = expansion_config.on_right_click.clone() {
return EventHandler::new(element)
.on_right_mouse_down(move |ctx, _, _| {
right_click_callback(ctx);
DispatchEventResult::PropagateToParent
})
.finish();
} else {
return element;
}
} else if let Some(InteractionMode::RightClickable(right_click_config)) =
&self.interaction_mode
{
let right_click_callback = right_click_config.on_right_click.clone();
let header_mouse_state = right_click_config.header_mouse_state.clone();
let hoverable = Hoverable::new(header_mouse_state, |_| container).finish();
return EventHandler::new(hoverable)
.on_right_mouse_down(move |ctx, _, _| {
right_click_callback(ctx);
DispatchEventResult::PropagateToParent
})
.finish();
}
container
}
pub fn render(self, app: &AppContext) -> Box<dyn Element> {
if let Some(interaction_mode) = self.interaction_mode.clone() {
let appearance: &Appearance = Appearance::as_ref(app);
match interaction_mode {
InteractionMode::ActionButtons {
action_buttons,
size_switch_threshold,
} => {
// Convert boxed trait objects into trait object references expected by the renderer
let button_refs: Vec<&dyn RenderCompactibleActionButton> =
action_buttons.iter().map(|b| b.as_ref()).collect();
let (regular_row, compact_row) =
render_compact_and_regular_button_rows(button_refs, None, appearance, app);
let regular_header = self.clone().render_header(app, Some(regular_row));
let compact_header = self.render_header(app, Some(compact_row));
let size_switch_threshold =
size_switch_threshold * appearance.monospace_ui_scalar();
SizeConstraintSwitch::new(
regular_header,
vec![(
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
compact_header,
)],
)
.finish()
}
InteractionMode::ManuallyExpandable(expansion_config) => {
let expanded_icon = ConstrainedBox::new(render_expansion_icon(
expansion_config.is_expanded,
expansion_config.expands_upwards,
appearance,
app,
))
.with_height(icon_size(app))
.with_width(icon_size(app))
.finish();
self.render_header(app, Some(expanded_icon))
}
InteractionMode::RightClickable(_) => self.render_header(app, None),
}
} else {
self.render_header(app, None)
}
}
}
@@ -0,0 +1,42 @@
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::AnsiColorIdentifier;
use warpui::{AppContext, SingletonEntity};
/// Returns the size for icons in the AI block, scaled to the user's current font size.
pub fn icon_size(app: &AppContext) -> f32 {
let appearance = Appearance::as_ref(app);
app.font_cache().line_height(
appearance.monospace_font_size(),
appearance.line_height_ratio(),
)
}
pub fn green_check_icon(appearance: &Appearance) -> warpui::elements::Icon {
warpui::elements::Icon::new(
Icon::Check.into(),
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal),
)
}
pub fn red_x_icon(appearance: &Appearance) -> warpui::elements::Icon {
warpui::elements::Icon::new(
Icon::X.into(),
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal),
)
}
pub fn cancelled_icon(appearance: &Appearance) -> warpui::elements::Icon {
warpui::elements::Icon::new(
Icon::Cancelled.into(),
blended_colors::neutral_6(appearance.theme()),
)
}
pub fn reverted_icon(appearance: &Appearance) -> warpui::elements::Icon {
warpui::elements::Icon::new(
Icon::ReverseLeft.into(),
blended_colors::neutral_6(appearance.theme()),
)
}
@@ -0,0 +1,195 @@
use ai::diff_validation::{DiffDelta, DiffType};
pub(super) fn proposed_terminal_line_range(diff_type: &DiffType) -> Option<std::ops::Range<usize>> {
let (terminal_delta, cumulative_line_shift) = terminal_update_delta_with_shift(diff_type)?;
let terminal_start = terminal_delta
.replacement_line_range
.start
.saturating_sub(1);
let terminal_start = terminal_start.saturating_add_signed(cumulative_line_shift);
let inserted_lines = inserted_line_count(&terminal_delta.insertion);
let terminal_end = terminal_start.saturating_add(inserted_lines);
Some(terminal_start..terminal_end.max(terminal_start))
}
pub(super) fn proposed_terminal_line_text(diff_type: &DiffType) -> Option<String> {
let terminal_delta = terminal_update_delta(diff_type)?;
terminal_delta
.insertion
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map(ToOwned::to_owned)
}
pub(super) fn looks_malformed_terminal_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
has_odd_unescaped_quote(trimmed, '"')
|| has_odd_unescaped_quote(trimmed, '\'')
|| has_odd_unescaped_quote(trimmed, '`')
|| trimmed.ends_with('\\')
|| has_trailing_incomplete_token(trimmed)
|| looks_like_function_declaration_without_body(trimmed)
}
pub(super) fn changed_lines_intersect_terminal_range(
changed_lines: &[std::ops::Range<usize>],
terminal_line_range: &std::ops::Range<usize>,
) -> bool {
changed_lines
.iter()
.any(|changed| range_intersects_or_contains_point(changed, terminal_line_range))
}
pub(super) fn has_malformed_terminal_correction_signal(
diff_type: &DiffType,
changed_lines: &[std::ops::Range<usize>],
) -> bool {
let Some(terminal_line_range) = proposed_terminal_line_range(diff_type) else {
return false;
};
let Some(terminal_line_text) = proposed_terminal_line_text(diff_type) else {
return false;
};
looks_malformed_terminal_line(&terminal_line_text)
&& changed_lines_intersect_terminal_range(changed_lines, &terminal_line_range)
}
fn terminal_update_delta(diff_type: &DiffType) -> Option<&DiffDelta> {
match diff_type {
DiffType::Update { deltas, .. } => deltas.iter().max_by_key(|delta| {
(
delta.replacement_line_range.end,
delta.replacement_line_range.start,
)
}),
DiffType::Create { .. } | DiffType::Delete { .. } => None,
}
}
fn terminal_update_delta_with_shift(diff_type: &DiffType) -> Option<(&DiffDelta, isize)> {
let DiffType::Update { deltas, .. } = diff_type else {
return None;
};
let terminal_delta = terminal_update_delta(diff_type)?;
let mut sorted_deltas: Vec<&DiffDelta> = deltas.iter().collect();
sorted_deltas.sort_by_key(|delta| {
(
delta.replacement_line_range.start,
delta.replacement_line_range.end,
)
});
let mut cumulative_line_shift = 0isize;
for delta in sorted_deltas {
if std::ptr::eq(delta, terminal_delta) {
return Some((terminal_delta, cumulative_line_shift));
}
let replaced_lines = delta
.replacement_line_range
.end
.saturating_sub(delta.replacement_line_range.start);
let inserted_lines = inserted_line_count(&delta.insertion);
cumulative_line_shift += inserted_lines as isize - replaced_lines as isize;
}
unreachable!(
"terminal_update_delta_with_shift: terminal delta not found in sorted update deltas"
)
}
fn inserted_line_count(insertion: &str) -> usize {
if insertion.is_empty() {
0
} else {
insertion.lines().count()
}
}
fn has_odd_unescaped_quote(line: &str, quote: char) -> bool {
let mut escaped = false;
let mut count = 0usize;
for ch in line.chars() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == quote {
count += 1;
}
}
count % 2 == 1
}
fn has_trailing_incomplete_token(line: &str) -> bool {
if line.starts_with("//") || line.starts_with('#') {
return false;
}
// Keep this list biased toward operators that are strongly code-like at EOL.
// Deliberately exclude ambiguous single-character tokens like ".", "-", "*", and "/"
// because they frequently appear in prose/markdown/path-like text and can inflate false positives.
[
"&&", "||", "??", "=>", "->", "::", "==", "!=", "<=", ">=", "+", "%", "=", "<", ">",
]
.iter()
.any(|token| line.ends_with(token))
}
fn starts_like_function_declaration(line: &str) -> bool {
[
"fn ",
"pub fn ",
"pub(crate) fn ",
"pub(super) fn ",
"pub async fn ",
"async fn ",
"def ",
"async def ",
"function ",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
fn looks_like_function_declaration_without_body(line: &str) -> bool {
if !starts_like_function_declaration(line) {
return false;
}
let has_params = line.contains('(') && line.contains(')');
let has_body_delimiter = line.contains('{') || line.ends_with(':');
let ends_like_declaration = line.ends_with(')') || line.contains("->");
let terminates_signature = line.ends_with(';');
has_params && ends_like_declaration && !has_body_delimiter && !terminates_signature
}
fn range_intersects_or_contains_point(
changed: &std::ops::Range<usize>,
terminal: &std::ops::Range<usize>,
) -> bool {
let changed_is_point = changed.start == changed.end;
let terminal_is_point = terminal.start == terminal.end;
if changed_is_point && terminal_is_point {
return changed.start == terminal.start;
}
if changed_is_point {
return terminal.start <= changed.start && changed.start < terminal.end;
}
if terminal_is_point {
return changed.start <= terminal.start && terminal.start < changed.end;
}
changed.start < terminal.end && terminal.start < changed.end
}
#[cfg(test)]
#[path = "malformed_line_heuristics_test.rs"]
mod tests;
@@ -0,0 +1,97 @@
use std::slice;
use super::{
changed_lines_intersect_terminal_range, has_malformed_terminal_correction_signal,
proposed_terminal_line_range,
};
use ai::diff_validation::{DiffDelta, DiffType};
#[test]
fn terminal_range_accounts_for_prior_insertions() {
let diff = DiffType::update(
vec![
DiffDelta {
replacement_line_range: 2..3,
insertion: "alpha\nbeta\ngamma\n".to_string(),
},
DiffDelta {
replacement_line_range: 5..6,
insertion: "let value = \"unterminated".to_string(),
},
],
None,
);
let terminal_range =
proposed_terminal_line_range(&diff).expect("terminal hunk should produce range");
assert_eq!(terminal_range, 6..7);
assert!(changed_lines_intersect_terminal_range(
slice::from_ref(&(6..7)),
&terminal_range
));
}
#[test]
fn terminal_range_accounts_for_prior_deletions() {
let diff = DiffType::update(
vec![
DiffDelta {
replacement_line_range: 2..5,
insertion: "one-line\n".to_string(),
},
DiffDelta {
replacement_line_range: 8..9,
insertion: "let value = \"unterminated".to_string(),
},
],
None,
);
let terminal_range =
proposed_terminal_line_range(&diff).expect("terminal hunk should produce range");
assert_eq!(terminal_range, 5..6);
assert!(changed_lines_intersect_terminal_range(
slice::from_ref(&(5..6)),
&terminal_range
));
}
#[test]
fn integration_metric_signal_true_for_shifted_multi_hunk_diff() {
let diff = DiffType::update(
vec![
DiffDelta {
replacement_line_range: 2..3,
insertion: "alpha\nbeta\ngamma\n".to_string(),
},
DiffDelta {
replacement_line_range: 5..6,
insertion: "let value = \"unterminated".to_string(),
},
],
None,
);
assert!(has_malformed_terminal_correction_signal(
&diff,
slice::from_ref(&(6..7)),
));
}
#[test]
fn integration_metric_signal_false_when_terminal_hunk_not_changed() {
let diff = DiffType::update(
vec![
DiffDelta {
replacement_line_range: 2..3,
insertion: "alpha\nbeta\ngamma\n".to_string(),
},
DiffDelta {
replacement_line_range: 5..6,
insertion: "let value = \"unterminated".to_string(),
},
],
None,
);
assert!(!has_malformed_terminal_correction_signal(
&diff,
slice::from_ref(&(2..3)),
));
}
+16
View File
@@ -0,0 +1,16 @@
pub(crate) mod ask_user_question_view;
pub(super) mod aws_bedrock_credentials_error;
pub(crate) mod code_diff_view;
pub(crate) mod create_or_edit_document;
pub(crate) mod inline_action_header;
pub(crate) mod inline_action_icons;
mod malformed_line_heuristics;
pub(crate) mod requested_action;
pub(crate) mod requested_command;
pub(crate) mod requested_command_attribution;
pub(crate) mod requested_script;
pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(crate) mod suggested_unit_tests;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -0,0 +1,608 @@
//! This module contains rendering functions for various requested inline actions that have not yet
//! been transformed into a [`View`] component. This currently encompasses UI for file retrieval,
//! environmental variable collection, and SSH Warpification, to name a few.
//!
//! There's quite a bit of duplication between function-based inline actions and view-based inline
//! actions. Moreover, the header rendering functions here don't make use of the HeaderConfig.
//!
//! Ideally, the modules that currently use the functions herein should be transformed
//! into [`View`] components as well. If that's ever deemed necessary, see [`RequestedCommandView`]
//! for an example on how that transformation could be made.
use lazy_static::lazy_static;
use markdown_parser::FormattedText;
use markdown_parser::FormattedTextFragment;
use markdown_parser::FormattedTextLine;
use pathfinder_color::ColorU;
use std::borrow::Cow;
use std::rc::Rc;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors::neutral_2;
use warpui::elements::Align;
use warpui::elements::Clipped;
use warpui::elements::FormattedTextElement;
use warpui::fonts::FamilyId;
use warpui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
Hoverable, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Text, Wrap, WrapFill,
},
keymap::Keystroke,
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, EventContext, SingletonEntity,
};
use super::inline_action_header::HeaderConfig;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ai::blocklist::inline_action::inline_action_header;
use crate::ai::blocklist::inline_action::inline_action_header::INLINE_ACTION_VERTICAL_PADDING;
use crate::ai::blocklist::inline_action::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ui_components::blended_colors;
const REQUESTED_ACTION_CANCEL_LABEL: &str = "Cancel";
const REQUESTED_ACTION_RUN_LABEL: &str = "Run";
const KEYBOARD_SHORTCUT_MARGIN_RIGHT: f32 = 8.;
lazy_static! {
pub static ref ENTER_KEYSTROKE: Keystroke = Keystroke {
key: "enter".to_owned(),
..Default::default()
};
pub static ref CMD_ENTER_KEYSTROKE: Keystroke =
Keystroke::parse("cmdorctrl-enter").expect("RUN_REQUESTED_ACTION_KEYSTROKE is invalid");
pub static ref CTRL_C_KEYSTROKE: Keystroke = Keystroke {
ctrl: true,
key: "c".to_owned(),
..Default::default()
};
pub static ref ESCAPE_KEYSTROKE: Keystroke = Keystroke {
key: "escape".to_owned(),
..Default::default()
};
}
pub(crate) enum FormattedTextOrElement {
FormattedText(Box<FormattedTextElement>),
Element(Box<dyn Element>),
}
impl From<FormattedTextElement> for FormattedTextOrElement {
fn from(value: FormattedTextElement) -> Self {
Self::FormattedText(Box::new(value))
}
}
/// Configuration for rendering a requested action component using the builder pattern.
pub struct RenderableAction {
body: FormattedTextOrElement,
action_button: Option<Box<dyn Element>>,
pub icon: Option<Box<dyn Element>>,
pub header: Option<HeaderConfig>,
pub footer: Option<Box<dyn Element>>,
pub background_color: ColorU,
pub should_highlight_border: bool,
should_override_with_content_item_spacing: bool,
}
impl RenderableAction {
pub fn new(text: &str, app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let formatted_text =
render_requested_action_body_text(text.into(), appearance.ui_font_family(), app);
Self {
body: FormattedTextOrElement::FormattedText(Box::new(formatted_text)),
icon: None,
header: None,
footer: None,
action_button: None,
background_color: neutral_2(theme),
should_highlight_border: false,
should_override_with_content_item_spacing: false,
}
}
pub fn new_with_formatted_text(formatted_text: FormattedTextElement, app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Self {
body: FormattedTextOrElement::FormattedText(Box::new(formatted_text)),
icon: None,
header: None,
footer: None,
action_button: None,
background_color: neutral_2(theme),
should_highlight_border: false,
should_override_with_content_item_spacing: false,
}
}
pub fn new_with_element(element: Box<dyn Element>, app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Self {
body: FormattedTextOrElement::Element(element),
icon: None,
header: None,
footer: None,
action_button: None,
background_color: neutral_2(theme),
should_highlight_border: false,
should_override_with_content_item_spacing: false,
}
}
pub fn with_icon(mut self, icon: Box<dyn Element>) -> Self {
self.icon = Some(icon);
self
}
pub fn with_header(mut self, header: HeaderConfig) -> Self {
self.header = Some(header);
self
}
pub fn with_footer(mut self, footer: Box<dyn Element>) -> Self {
self.footer = Some(footer);
self
}
pub fn with_font_color(mut self, color: ColorU) -> Self {
if let FormattedTextOrElement::FormattedText(formatted_text) = self.body {
self.body =
FormattedTextOrElement::FormattedText(Box::new(formatted_text.with_color(color)));
}
self
}
pub fn with_background_color(mut self, color: ColorU) -> Self {
self.background_color = color;
self
}
pub fn with_highlighted_border(mut self) -> Self {
self.should_highlight_border = true;
self
}
pub fn with_action_button(mut self, button: Box<dyn Element>) -> Self {
self.action_button = Some(button);
self
}
pub fn with_content_item_spacing(mut self) -> Self {
self.should_override_with_content_item_spacing = true;
self
}
/// Renders the requested action with the current configuration.
pub fn render(self, app: &AppContext) -> Container {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut has_header = false;
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
if let Some(header) = self.header {
content.add_child(Clipped::new(header.render(app)).finish());
has_header = true;
}
content.add_child(render_requested_action_row(
self.body,
self.icon,
self.action_button,
true,
has_header,
app,
));
if let Some(footer) = self.footer {
content.add_child(
Container::new(footer)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(4.)
.with_background(theme.surface_1())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
let container = Container::new(content.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background_color(self.background_color)
.with_border(
Border::all(1.).with_border_fill(if self.should_highlight_border {
theme.accent()
} else {
theme.surface_2()
}),
);
if has_header || self.should_override_with_content_item_spacing {
container.finish().with_content_item_spacing()
} else {
container.finish().with_agent_output_item_spacing(app)
}
}
}
/// Create the buttons representing Run and Cancel on the requested action.
/// Note that keyboard events aren't automatically propagated to the buttons.
#[allow(clippy::too_many_arguments)]
pub(super) fn render_header_buttons(
on_accept: impl Fn(&mut EventContext) + 'static,
on_cancel: impl Fn(&mut EventContext) + 'static,
run_keystroke: &Keystroke,
cancel_keystroke: &Keystroke,
run_button: &MouseStateHandle,
cancel_button: &MouseStateHandle,
should_show_accept_button: bool,
app: &AppContext,
) -> Box<dyn Element> {
const BUTTON_MARGIN_RIGHT: f32 = 16.;
let appearance = Appearance::as_ref(app);
let width_required_for_full_size_layout = approx_keystroke_button_width(
REQUESTED_ACTION_CANCEL_LABEL,
appearance.monospace_font_size(),
cancel_keystroke,
None,
app,
) + BUTTON_MARGIN_RIGHT
+ approx_keystroke_button_width(
REQUESTED_ACTION_RUN_LABEL,
appearance.monospace_font_size(),
run_keystroke,
None,
app,
)
+ INLINE_ACTION_HORIZONTAL_PADDING;
let compact_button_font_size = (appearance.monospace_font_size() - 2.).max(4.);
let compact_button_styles = UiComponentStyles {
font_size: Some(compact_button_font_size),
..Default::default()
};
let width_required_for_compact_layout = approx_keystroke_button_width(
REQUESTED_ACTION_CANCEL_LABEL,
compact_button_font_size,
cancel_keystroke,
Some(compact_button_styles),
app,
)
.max(approx_keystroke_button_width(
REQUESTED_ACTION_RUN_LABEL,
compact_button_font_size,
run_keystroke,
Some(compact_button_styles),
app,
));
let cancel_callback = Rc::new(on_cancel);
let cancel_clone = Rc::clone(&cancel_callback);
let accept_callback = Rc::new(on_accept);
let accept_clone = Rc::clone(&accept_callback);
let mut default_row = Flex::row().with_child(
Container::new(render_keyboard_shortcut_button(
REQUESTED_ACTION_CANCEL_LABEL,
Some(cancel_keystroke.clone()),
cancel_button.clone(),
cancel_callback,
None,
app,
))
.with_margin_right(BUTTON_MARGIN_RIGHT)
.finish(),
);
let mut size_constrained_column = Flex::column().with_child(render_keyboard_shortcut_button(
REQUESTED_ACTION_CANCEL_LABEL,
Some(cancel_keystroke.clone()),
cancel_button.clone(),
cancel_clone,
Some(compact_button_styles),
app,
));
if should_show_accept_button {
default_row.add_child(render_keyboard_shortcut_button(
REQUESTED_ACTION_RUN_LABEL,
Some(run_keystroke.clone()),
run_button.clone(),
accept_callback,
None,
app,
));
size_constrained_column.add_child(
Container::new(render_keyboard_shortcut_button(
REQUESTED_ACTION_RUN_LABEL,
Some(run_keystroke.clone()),
run_button.clone(),
accept_clone,
Some(compact_button_styles),
app,
))
.with_margin_top(8.)
.finish(),
);
}
SizeConstraintSwitch::new(
default_row.finish(),
vec![
(
SizeConstraintCondition::WidthLessThan(width_required_for_compact_layout),
Empty::new().finish(),
),
(
SizeConstraintCondition::WidthLessThan(width_required_for_full_size_layout),
size_constrained_column.finish(),
),
],
)
.finish()
}
pub fn render_requested_action_body_text(
text: Cow<str>,
font_family: FamilyId,
app: &AppContext,
) -> FormattedTextElement {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Split text into lines and create FormattedTextLine for each
let lines = text
.lines()
.map(|line| {
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(line.to_owned())])
})
.collect::<Vec<_>>();
let formatted_text = FormattedText::new(lines);
FormattedTextElement::new(
formatted_text.clone(),
appearance.monospace_font_size(),
font_family,
font_family,
blended_colors::text_main(theme, theme.background()),
Default::default(),
)
.with_color(blended_colors::text_main(theme, theme.background()))
.set_selectable(true)
}
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
pub fn render_requested_action_row_for_text(
text: Cow<str>,
font_family: FamilyId,
icon: Option<Box<dyn Element>>,
acton_button: Option<Box<dyn Element>>,
is_text_selectable: bool,
has_header_above: bool,
app: &AppContext,
) -> Box<dyn Element> {
render_requested_action_row(
render_requested_action_body_text(text, font_family, app).into(),
icon,
acton_button,
is_text_selectable,
has_header_above,
app,
)
}
/// Renders a full-width, rounded rectangular row with the specified text and a custom icon.
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
pub(crate) fn render_requested_action_row(
text: FormattedTextOrElement,
icon: Option<Box<dyn Element>>,
action_button: Option<Box<dyn Element>>,
is_text_selectable: bool,
has_header_above: bool,
app: &AppContext,
) -> Box<dyn Element> {
let element = match text {
FormattedTextOrElement::FormattedText(formatted_text) => {
formatted_text.set_selectable(is_text_selectable).finish()
}
FormattedTextOrElement::Element(element) => element,
};
render_requested_action_row_for_element(element, icon, action_button, has_header_above, app)
}
/// Renders a full-width, rounded rectangular row with the specified text and a custom icon.
/// Note that [`is_text_selectable`] is used to determine whether text selections are rendered.
/// A [`SelectableArea`] ancestor element is required to maintain functional text selection logic.
fn render_requested_action_row_for_element(
element: Box<dyn Element>,
icon: Option<Box<dyn Element>>,
action_button: Option<Box<dyn Element>>,
has_header_above: bool,
app: &AppContext,
) -> Box<dyn Element> {
let has_action_button = action_button.is_some();
let mut text_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(icon) = icon {
text_row.add_child(
Container::new(
ConstrainedBox::new(icon)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish(),
)
.with_margin_right(inline_action_header::ICON_MARGIN)
.finish(),
);
}
// When an action button is present we use a Wrap layout (below) so the button
// flows to a second row on narrow panes. In that case we must NOT wrap the text
// in Align, because Align always reports the full constraint width, which would
// inflate the text row and force the button to a new line unconditionally.
if has_action_button {
text_row.add_child(Shrinkable::new(1., element).finish());
} else {
text_row.add_child(Shrinkable::new(1., Align::new(element).left().finish()).finish());
}
let content = if let Some(action_button) = action_button {
let button_element = Container::new(action_button)
.with_margin_right(inline_action_header::ICON_MARGIN)
.finish();
let mut wrap = Wrap::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_run_spacing(8.);
wrap.extend([
WrapFill::new(0., text_row.finish()).finish(),
button_element,
]);
wrap.finish()
} else {
text_row.finish()
};
Container::new(content)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
// The requested action row is currently overloaded, being used in two distinct ways:
// 1) To display the body content of an inline requested action (with a header rendered above)
// 2) To display the header of a non-expandable inline requested action
.with_vertical_padding(if has_header_above {
INLINE_ACTION_VERTICAL_PADDING
} else {
INLINE_ACTION_HEADER_VERTICAL_PADDING
})
.finish()
}
pub fn render_keyboard_shortcut_button(
label: &'static str,
keystroke: Option<Keystroke>,
mouse_state: MouseStateHandle,
on_click: Rc<impl Fn(&mut EventContext) + 'static>,
style_overrides: Option<UiComponentStyles>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Hoverable::new(mouse_state, |mouse_state| {
let text_color = if mouse_state.is_hovered() {
blended_colors::accent(theme).into_solid()
} else {
blended_colors::text_main(theme, theme.surface_1())
};
let mut shortcut_styles = UiComponentStyles {
font_color: Some(text_color),
font_size: Some(appearance.monospace_font_size()),
background: Some(neutral_2(theme).into()),
..Default::default()
};
if let Some(style_overrides) = style_overrides {
shortcut_styles = shortcut_styles.merge(style_overrides)
}
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(keystroke) = keystroke {
row.add_child(
Container::new(
appearance
.ui_builder()
.keyboard_shortcut(&keystroke)
.with_style(shortcut_styles)
.build()
.finish(),
)
.with_margin_right(KEYBOARD_SHORTCUT_MARGIN_RIGHT)
.finish(),
);
}
row.with_child(
Text::new_inline(
label,
appearance.ui_font_family(),
shortcut_styles
.font_size
.unwrap_or(appearance.monospace_font_size()),
)
.with_color(text_color)
.with_selectable(false)
.finish(),
)
.finish()
})
.on_click(move |ctx, _, _| on_click(ctx))
.with_cursor(Cursor::PointingHand)
.finish()
}
fn approx_keystroke_button_width(
label: &str,
label_font_size: f32,
keystroke: &Keystroke,
keystroke_style_overrides: Option<UiComponentStyles>,
app: &AppContext,
) -> f32 {
let appearance = Appearance::as_ref(app);
let approx_text_width = (label.len() as f32)
* app
.font_cache()
.em_width(appearance.ui_font_family(), label_font_size);
let keystroke_len_ems = {
let mut len = 0;
if keystroke.ctrl {
len += 2
}
if keystroke.alt {
len += 2
}
if keystroke.shift {
len += 2
}
if keystroke.cmd {
len += 2
}
if keystroke.meta {
len += 2
}
len += keystroke.key.len();
len
};
let mut keystroke_styles = appearance.ui_builder().default_keyboard_shortcut_styles();
if let Some(style_overrides) = keystroke_style_overrides {
keystroke_styles = keystroke_styles.merge(style_overrides);
}
let approx_keystroke_width = (keystroke_len_ems as f32)
* app.font_cache().em_width(
appearance.ui_font_family(),
keystroke_styles
.font_size
.unwrap_or(appearance.ui_font_size()),
)
+ keystroke_styles
.padding
.map(|padding| padding.right + padding.left)
.unwrap_or(0.)
+ keystroke_styles
.margin
.map(|margin| margin.right + margin.left)
.unwrap_or(0.);
approx_text_width + KEYBOARD_SHORTCUT_MARGIN_RIGHT + approx_keystroke_width
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
//! Module to attribute AI-generated requested commands
//! to known documents (e.g. Warp Drive objects).
use warpui::AppContext;
use warpui::SingletonEntity;
use crate::env_vars::EnvVarCollection;
use crate::env_vars::EnvVarValue;
use crate::notebooks::CloudNotebookModel;
use crate::terminal::shell::ShellType;
use crate::{
ai::agent::AIAgentCitation, cloud_object::model::persistence::CloudModel,
workflows::command_parser::command_matches_workflow,
};
use markdown_parser::{parse_markdown, FormattedTextLine};
/// Returns true iff the `command` is directly copied from the `document`.
pub(crate) fn is_command_copied_from_document(
command: &str,
document: &AIAgentCitation,
shell_type: Option<ShellType>,
ctx: &AppContext,
) -> bool {
let command = command.trim();
match document {
AIAgentCitation::WarpDriveObject { uid } => {
is_command_copied_from_warp_drive_object(command, uid, shell_type, ctx)
}
_ => false,
}
}
/// Returns true iff the `command` is directly copied from the
/// Warp Drive object identified by `object_uid`.
fn is_command_copied_from_warp_drive_object(
command: &str,
object_uid: &str,
shell_type: Option<ShellType>,
ctx: &AppContext,
) -> bool {
if let Some(workflow) = CloudModel::as_ref(ctx).get_workflow_by_uid(object_uid) {
command_matches_workflow(command, &workflow.model().data)
} else if let Some(notebook) = CloudModel::as_ref(ctx).get_notebook_by_uid(object_uid) {
is_command_copied_from_notebook(command, notebook.model())
} else if let Some((env_var_collection, shell_type)) = CloudModel::as_ref(ctx)
.get_env_var_collection_by_uid(object_uid)
.zip(shell_type)
{
is_command_copied_from_env_var_collection(
command,
&env_var_collection.model().string_model,
shell_type,
)
} else {
false
}
}
/// Returns true iff the `command` was copied directly from one of the
/// notebook's code blocks.
fn is_command_copied_from_notebook(command: &str, notebook: &CloudNotebookModel) -> bool {
let Ok(md) = parse_markdown(notebook.data.as_str()) else {
return false;
};
for line in md.lines {
if let FormattedTextLine::CodeBlock(code) = line {
if command == code.code.trim() {
return true;
}
}
}
false
}
/// Returns true iff the `command` was copied from one of the env-vars
/// in the `collection`.
///
/// TODO: it'd be ideal to attribute the command to the env-var (collection)
/// if the name of the var didn't necessarily match but the value still did.
fn is_command_copied_from_env_var_collection(
command: &str,
collection: &EnvVarCollection,
shell_type: ShellType,
) -> bool {
// Check if the command is an instantiation of all the env-vars in the collection.
if collection.export_variables_for_shell(shell_type) == command {
return true;
}
for var in &collection.vars {
// Check if the env-var is defined as a command and matches the given command exactly.
if let EnvVarValue::Command(secret_command) = &var.value {
if secret_command.command == command {
return true;
}
}
// Check if the command is an initialization of the specific env-var.
let init_string = var.get_initialization_string(shell_type);
if init_string == command || init_string.trim_end_matches(";") == command {
return true;
}
}
false
}
#[cfg(test)]
#[path = "requested_command_attribution_test.rs"]
mod tests;
@@ -0,0 +1,65 @@
use crate::env_vars::{view::command_dialog::EnvVarSecretCommand, EnvVar};
use super::*;
#[test]
fn test_is_command_copied_from_notebook() {
let notebook = CloudNotebookModel {
title: String::from(""),
data: String::from("hello world\n```\n foobar \n```\n"),
ai_document_id: None,
conversation_id: None,
};
assert!(!is_command_copied_from_notebook("hello world", &notebook));
assert!(!is_command_copied_from_notebook("foo", &notebook));
assert!(is_command_copied_from_notebook("foobar", &notebook));
}
#[test]
fn test_is_command_copied_from_env_var_collection() {
let shell_type = ShellType::Zsh;
let collection = EnvVarCollection {
title: None,
description: None,
vars: vec![
EnvVar {
name: String::from("GCP_TOKEN"),
description: None,
value: EnvVarValue::Command(EnvVarSecretCommand {
name: String::from("gcloud cmd"),
command: String::from("gcloud print auth token"),
}),
},
EnvVar {
name: String::from("OPENAI_KEY"),
description: None,
value: EnvVarValue::Command(EnvVarSecretCommand {
name: String::from("openai"),
command: String::from("openai --token"),
}),
},
],
};
assert!(!is_command_copied_from_env_var_collection(
"gcloud",
&collection,
shell_type
));
assert!(is_command_copied_from_env_var_collection(
"gcloud print auth token",
&collection,
shell_type
));
assert!(is_command_copied_from_env_var_collection(
"export GCP_TOKEN=$(gcloud print auth token)",
&collection,
shell_type
));
// TODO(suraj): enable this after fixing how we export env-vars
// assert!(is_command_copied_from_env_var_collection(
// "export GCP_TOKEN=$(gcloud print auth token); export OPENAI_KEY=$(openai --token);",
// &collection,
// shell_type
// ));
}
@@ -0,0 +1,72 @@
//! Unit tests for format_command_text in requested_command.rs
use super::format_command_text;
#[test]
fn single_line_without_newline_is_unchanged_ascii() {
let input = "echo hello world";
let output = format_command_text(input);
assert_eq!(output, input);
}
#[test]
fn single_line_without_newline_preserves_multibyte_characters() {
let input = "echo 🚀✨";
let output = format_command_text(input);
assert_eq!(output, input);
// Additional sanity check: string is valid UTF-8 and can be iterated by chars without panic
let collected: String = output.chars().collect();
assert_eq!(collected, output);
}
#[test]
fn truncates_at_first_newline_and_appends_ellipsis_when_more_content_exists() {
let input = "cargo build\n--release";
let output = format_command_text(input);
assert_eq!(output, "cargo build…");
}
#[test]
fn truncates_at_first_newline_without_ellipsis_when_rest_is_whitespace() {
let input = "git status\n \t ";
let output = format_command_text(input);
assert_eq!(output, "git status");
}
#[test]
fn does_not_split_multibyte_char_across_utf8_boundaries_when_newline_follows() {
// The emoji is a multi-byte sequence; ensure truncation at the newline does not split it.
let input = "echo 🧪\nthen do something";
let output = format_command_text(input);
assert_eq!(output, "echo 🧪…");
// Validate resulting string is valid UTF-8 by iterating graphemes via chars
let reconstructed: String = output.chars().collect();
assert_eq!(reconstructed, output);
}
#[test]
fn preserves_combining_characters_when_newline_is_after_cluster() {
// "e" + combining acute accent
// Sanity checks that the formatter doesn't split this unicode sequence
let composed = format!("{}{}", 'e', '\u{0301}');
let input = format!("echo {composed}\nnext");
let output = format_command_text(&input);
assert_eq!(output, format!("echo {composed}…"));
// Still valid UTF-8 and same when re-collected from chars
let reconstructed: String = output.chars().collect();
assert_eq!(reconstructed, output);
}
#[test]
fn newline_then_multibyte_results_in_ellipsis_only() {
let input = "\n🚀";
let output = format_command_text(input);
assert_eq!(output, "…");
// Sanity: output remains valid UTF-8
let reconstructed: String = output.chars().collect();
assert_eq!(reconstructed, output);
}
@@ -0,0 +1,363 @@
//! This module is similar to `requested_actions.rs` but specifically for scripts instead of commands.
//! Eventually, we'll probably want to refactor the functions here to lean on Views instead.
use pathfinder_geometry::vector::Vector2F;
use std::rc::Rc;
use warp_core::ui::appearance::Appearance;
use warpui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
Wrap,
};
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::toggle_menu::{
ToggleMenuCallback, ToggleMenuItem, ToggleMenuStateHandle,
};
use warpui::{AppContext, Element, EventContext, SingletonEntity};
use super::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use super::requested_action::render_header_buttons;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::inline_action::requested_action::render_requested_action_row_for_text;
use crate::ui_components::icons::Icon;
pub struct TitledScript {
pub title: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestedScriptStatus {
/// The action is loaded and is waiting to be acted upon by the user.
WaitingForUser,
/// The action is currently running, after being accepted by the user.
Running,
}
#[derive(Default, Clone)]
pub struct RequestedScriptMouseStates {
pub show_hide_button: MouseStateHandle,
pub cancel_button: MouseStateHandle,
pub run_button: MouseStateHandle,
}
/// Creates a requested script element that gives the option to hide or show the script.
#[allow(clippy::too_many_arguments)]
pub fn render_requested_script(
header: &str,
script: &str,
status: RequestedScriptStatus,
is_collapsed: bool,
is_viewing_detail: bool,
on_toggle_expanded: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
on_accept: impl Fn(&mut EventContext) + 'static,
on_cancel: impl Fn(&mut EventContext) + 'static,
run_keystroke: &Keystroke,
cancel_keystroke: &Keystroke,
button_states: &RequestedScriptMouseStates,
should_highlight_border: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let is_executing = matches!(status, RequestedScriptStatus::Running);
let should_show_accept_button = matches!(status, RequestedScriptStatus::WaitingForUser);
// The current status of the script: whether it is executing, expanded, or collapsed.
let (action_text, icon) = script_status(is_executing, is_collapsed, is_viewing_detail, app);
content.add_child(render_header(
Text::new(
header.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(theme.main_text_color(theme.surface_1()).into())
.finish(),
on_accept,
on_cancel,
run_keystroke,
cancel_keystroke,
button_states,
should_show_accept_button,
!is_executing,
app,
));
content.add_child(
Hoverable::new(button_states.show_hide_button.clone(), |_| {
render_requested_action_row_for_text(
action_text.into(),
appearance.ui_font_family(),
Some(icon),
None,
false,
true,
app,
)
})
.on_click(on_toggle_expanded)
.with_cursor(Cursor::PointingHand)
.finish(),
);
// If expanded, the contents of the script.
if !is_collapsed {
content.add_child(render_requested_action_row_for_text(
script.into(),
appearance.ui_font_family(),
None,
None,
false,
true,
app,
));
}
let border_color =
if should_highlight_border && matches!(status, RequestedScriptStatus::WaitingForUser) {
theme.accent().into_solid()
} else {
theme.surface_2().into_solid()
};
content
.finish()
.with_content_item_spacing()
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background_color(theme.background().into_solid())
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
/// Creates a requested script element that gives the option to hide or show the scripts.
#[allow(clippy::too_many_arguments)]
pub fn render_requested_scripts(
first_script: TitledScript,
second_script: TitledScript,
is_first_script_active: bool,
status: RequestedScriptStatus,
is_collapsed: bool,
is_viewing_detail: bool,
on_toggle_expanded: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
on_accept: impl Fn(&mut EventContext) + 'static,
on_cancel: impl Fn(&mut EventContext) + 'static,
run_keystroke: &Keystroke,
cancel_keystroke: &Keystroke,
button_states: &RequestedScriptMouseStates,
toggle_menu_mouse_states: Vec<MouseStateHandle>,
toggle_menu_state_handle: ToggleMenuStateHandle,
on_toggle_change: Rc<ToggleMenuCallback>,
should_highlight_border: bool,
toggle_menu_width: f32,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
let is_executing = matches!(status, RequestedScriptStatus::Running);
let should_show_accept_button = matches!(status, RequestedScriptStatus::WaitingForUser);
// The current status of the script: whether it is executing, expanded, or collapsed.
let (action_text, icon) = script_status(is_executing, is_collapsed, is_viewing_detail, app);
let selected_color = theme.surface_3();
let toggle_menu: Box<dyn Element> = ConstrainedBox::new(
appearance
.ui_builder()
.toggle_menu(
toggle_menu_mouse_states,
vec![
ToggleMenuItem::new(first_script.title.clone()),
ToggleMenuItem::new(second_script.title.clone()),
],
toggle_menu_state_handle,
Some((!is_first_script_active).into()),
None,
None,
None,
appearance.ui_font_size(),
on_toggle_change,
)
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 10.,
right: 12.,
bottom: 10.,
left: 12.,
}),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(0.))),
background: Some(theme.background().into()),
border_color: Some(selected_color.into()),
margin: Some(Coords {
top: 0.,
right: 0.,
bottom: 0.,
left: 0.,
}),
..Default::default()
})
.with_disabled(is_executing)
.build()
.with_uniform_padding(0.)
.with_border(Border::all(2.).with_border_fill(selected_color.into_solid()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish(),
)
.with_width(toggle_menu_width)
.finish();
content.add_child(render_header(
toggle_menu,
on_accept,
on_cancel,
run_keystroke,
cancel_keystroke,
button_states,
should_show_accept_button,
!is_executing,
app,
));
content.add_child(
Hoverable::new(button_states.show_hide_button.clone(), |_| {
render_requested_action_row_for_text(
action_text.into(),
appearance.ui_font_family(),
Some(icon),
None,
false,
true,
app,
)
})
.on_click(on_toggle_expanded)
.with_cursor(Cursor::PointingHand)
.finish(),
);
// If expanded, the contents of the script.
if !is_collapsed {
let script = if is_first_script_active {
first_script.content
} else {
second_script.content
};
content.add_child(render_requested_action_row_for_text(
script.into(),
appearance.ui_font_family(),
None,
None,
false,
true,
app,
));
}
let border_color =
if should_highlight_border && matches!(status, RequestedScriptStatus::WaitingForUser) {
theme.accent().into_solid()
} else {
theme.surface_2().into_solid()
};
content
.finish()
.with_content_item_spacing()
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background_color(theme.background().into_solid())
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
/// Renders the header for the requested script, with optional action buttons.
#[allow(clippy::too_many_arguments)]
fn render_header(
header: Box<dyn Element>,
on_accept: impl Fn(&mut EventContext) + 'static,
on_cancel: impl Fn(&mut EventContext) + 'static,
run_keystroke: &Keystroke,
cancel_keystroke: &Keystroke,
mouse_states: &RequestedScriptMouseStates,
should_show_accept_button: bool,
is_interactive: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut row = Wrap::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_run_spacing(8.)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(3., header).finish());
if is_interactive {
row.add_child(
Shrinkable::new(
1.,
Align::new(render_header_buttons(
on_accept,
on_cancel,
run_keystroke,
cancel_keystroke,
&mouse_states.run_button,
&mouse_states.cancel_button,
should_show_accept_button,
app,
))
.right()
.finish(),
)
.finish(),
);
}
Container::new(row.finish())
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_background(theme.surface_1())
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
.finish()
}
/// Helper function to determine the correct contents for the first row in the requested script
/// element. The contents consist of a label and an icon.
fn script_status(
is_executing: bool,
is_collapsed: bool,
is_viewing_detail: bool,
app: &AppContext,
) -> (&str, Box<dyn Element>) {
let appearance = Appearance::as_ref(app);
let label = match (is_executing, is_collapsed) {
(true, _) => "Running...",
(false, true) => "Expand to show script",
(false, false) => "Hide",
};
let is_expanded = (is_executing && is_viewing_detail) || (!is_executing && !is_collapsed);
let icon = ConstrainedBox::new(
warpui::elements::Icon::new(
if is_expanded {
Icon::ChevronDown.into()
} else {
Icon::ChevronRight.into()
},
appearance.theme().foreground(),
)
.finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
(label, icon)
}
@@ -0,0 +1,518 @@
use std::ops::Range;
use std::sync::{Arc, RwLock};
use warp_core::ui::appearance::Appearance;
use warpui::ui_components::components::UiComponentStyles;
use warpui::{
elements::{
Container, CornerRadius, CrossAxisAlignment, Element, Flex, FormattedTextElement,
MainAxisAlignment, ParentElement, Radius, SelectableArea, SelectionHandle, Shrinkable,
},
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::search_results_common::{
render_collapsible_search_results, CollapsibleSearchResultsState,
};
use crate::ai::blocklist::TextLocation;
use crate::ai::{
agent::icons::yellow_running_icon,
blocklist::inline_action::{
inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
},
inline_action_icons::cancelled_icon,
},
};
use crate::ai::{
agent::FileContext,
blocklist::{
action_model::AIActionStatus,
block::{
find::FindState,
secret_redaction::SecretRedactionState,
view_impl::{
output::{
render_read_files_text, LinkActionConstructors, RenderContext,
RenderReadFileArg,
},
FindContext, WithContentItemSpacing,
},
},
},
};
use crate::terminal::view::RichContentLink;
use crate::terminal::{find::TerminalFindModel, ShellLaunchData};
use crate::util::link_detection::{
detect_links, DetectedLinkType, DetectedLinksState, LinkLocation,
};
pub enum SearchCodebaseViewEvent {
OpenLinkTooltip {
rich_content_link: RichContentLink,
},
#[cfg(feature = "local_fs")]
OpenDetectedFilePath {
absolute_path: std::path::PathBuf,
line_and_column_num: Option<warp_util::path::LineAndColumnArg>,
},
TextSelected,
}
#[derive(Clone, Debug)]
pub enum SearchCodebaseViewAction {
ToggleExpanded,
OpenLink {
link_range: Range<usize>,
location: TextLocation,
},
ChangedHoverOnLink {
link_range: Range<usize>,
location: TextLocation,
is_hovering: bool,
},
OpenLinkTooltip {
location: TextLocation,
link_range: Range<usize>,
},
SelectText,
}
pub struct SearchCodebaseView {
shell_launch_data: Option<ShellLaunchData>,
current_working_directory: Option<String>,
detected_links_state: DetectedLinksState,
secret_redaction_state: SecretRedactionState,
find_model: ModelHandle<TerminalFindModel>,
find_state: FindState,
file_contexts: Vec<FileContext>,
search_query: String,
repo_name: Option<String>,
collapsible: CollapsibleSearchResultsState,
selection_handle: SelectionHandle,
selected_text: Arc<RwLock<Option<String>>>,
action_index: usize,
status: Option<AIActionStatus>,
}
impl SearchCodebaseView {
pub fn new(
find_model: ModelHandle<TerminalFindModel>,
file_contexts: Vec<FileContext>,
search_query: String,
repo_path: Option<String>,
shell_launch_data: &Option<ShellLaunchData>,
current_working_directory: &Option<String>,
action_index: usize,
) -> Self {
let repo_name = repo_path.as_ref().and_then(|path| {
std::path::Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.map(|s| s.to_string())
});
Self {
detected_links_state: DetectedLinksState::default(),
secret_redaction_state: Default::default(),
find_model,
find_state: FindState::default(),
file_contexts,
search_query,
repo_name,
collapsible: CollapsibleSearchResultsState::new(),
selection_handle: SelectionHandle::default(),
selected_text: Arc::new(RwLock::new(None)),
action_index,
status: None,
shell_launch_data: shell_launch_data.clone(),
current_working_directory: current_working_directory.clone(),
}
}
pub fn update_status(&mut self, status: Option<AIActionStatus>) {
self.status = status;
}
pub fn update_render_read_file_args(
&mut self,
find_state: &FindState,
file_contexts: Vec<FileContext>,
status: Option<AIActionStatus>,
) {
self.find_state = find_state.clone();
self.file_contexts = file_contexts;
self.status = status;
self.detected_links_state.detected_links_by_location.clear();
for (line_index, file_context) in self.file_contexts.iter().enumerate() {
let text_location = TextLocation::Action {
action_index: self.action_index,
line_index,
};
let file_display = file_context.to_string();
detect_links(
&mut self.detected_links_state,
&file_display,
text_location,
self.current_working_directory.as_ref(),
self.shell_launch_data.as_ref(),
);
}
}
fn render_finished(
&self,
_appearance: &Appearance,
file_contexts: &[FileContext],
app: &AppContext,
) -> Box<dyn Element> {
let title_text = if let Some(repo_name) = &self.repo_name {
format!("Searched for \"{}\" in {}", self.search_query, repo_name)
} else {
format!("Searched for \"{}\"", self.search_query)
};
let body = if self.collapsible.is_expanded {
Some(self.render_results_body(file_contexts, app))
} else {
None
};
render_collapsible_search_results(
title_text,
file_contexts.len(),
"results",
&self.collapsible,
body,
|ctx| {
ctx.dispatch_typed_action(SearchCodebaseViewAction::ToggleExpanded);
},
app,
)
}
fn render_results_body(
&self,
file_contexts: &[FileContext],
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let render_read_file_args = RenderReadFileArg::new(
RenderContext {
shell_launch_data: self.shell_launch_data.as_ref(),
current_working_directory: self.current_working_directory.as_ref(),
detected_links_state: &self.detected_links_state,
secret_redaction_state: &self.secret_redaction_state,
},
self.find_model
.as_ref(app)
.is_find_bar_open()
.then_some(FindContext {
model: self.find_model.as_ref(app),
state: &self.find_state,
}),
self.selection_handle.is_selecting(),
LinkActionConstructors {
construct_open_link_action: |link_range, location| {
SearchCodebaseViewAction::OpenLink {
link_range,
location,
}
},
construct_open_link_tooltip_action: |link_range, location| {
SearchCodebaseViewAction::OpenLinkTooltip {
link_range,
location,
}
},
construct_changed_hover_on_link_action: |link_range, location, is_hovering| {
SearchCodebaseViewAction::ChangedHoverOnLink {
link_range,
location,
is_hovering,
}
},
},
);
let files: Box<dyn Element> = if file_contexts.is_empty() {
let no_results_style = UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.monospace_font_size()),
..Default::default()
};
self.render_formatted_text("No results found".to_string(), no_results_style, appearance)
} else {
render_read_files_text(
render_read_file_args,
file_contexts.iter().map(|fc| fc.to_string()),
app,
appearance,
self.action_index,
)
.finish()
};
let selected_text = self.selected_text.clone();
SelectableArea::new(
self.selection_handle.clone(),
#[allow(clippy::unwrap_used)]
move |selection_args, _, _| {
*selected_text.write().unwrap() = selection_args.selection;
},
files,
)
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(SearchCodebaseViewAction::SelectText);
})
.finish()
}
fn create_header_row(&self) -> Flex {
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
}
fn create_header_text_style(
&self,
appearance: &Appearance,
header_background: warp_core::ui::theme::Fill,
) -> UiComponentStyles {
UiComponentStyles {
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(appearance.monospace_font_size()),
font_color: Some(
appearance
.theme()
.main_text_color(header_background)
.into_solid(),
),
..Default::default()
}
}
fn create_header_container(
&self,
header_row: Box<dyn Element>,
header_background: warp_core::ui::theme::Fill,
corner_radius: CornerRadius,
app: &AppContext,
) -> Box<dyn Element> {
Container::new(header_row)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(corner_radius)
.finish()
.with_agent_output_item_spacing(app)
.finish()
}
fn render_simple_header(&self, text: String, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let text_style = self.create_header_text_style(appearance, header_background);
let mut header_row = self.create_header_row();
let title_element = self.render_formatted_text(text, text_style, appearance);
header_row.add_child(Shrinkable::new(1.0, title_element).finish());
self.create_header_container(
header_row.finish(),
header_background,
CornerRadius::with_all(Radius::Pixels(8.)),
app,
)
}
fn render_header(
&self,
_appearance: &Appearance,
text: String,
icon: warpui::elements::Icon,
app: &AppContext,
) -> Box<dyn Element> {
super::search_results_common::render_loading_header(text, icon, app)
}
pub fn clear_link_tooltip(&mut self, ctx: &mut ViewContext<Self>) {
self.detected_links_state.link_location_open_tooltip = None;
ctx.notify();
}
pub fn clear_selection(&mut self, _ctx: &mut ViewContext<Self>) {
self.selection_handle.clear();
}
pub fn selected_text(&self, _ctx: &AppContext) -> Option<String> {
self.selected_text.read().unwrap().clone()
}
fn render_formatted_text(
&self,
text: String,
style: UiComponentStyles,
appearance: &Appearance,
) -> Box<dyn Element> {
let font_family = style
.font_family_id
.unwrap_or_else(|| appearance.ui_font_family());
let font_size = style.font_size.unwrap_or_else(|| appearance.ui_font_size());
let text_color = style.font_color.unwrap_or_else(|| {
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
.into_solid()
});
FormattedTextElement::from_str(text, font_family, font_size)
.with_color(text_color)
.finish()
}
}
impl Entity for SearchCodebaseView {
type Event = SearchCodebaseViewEvent;
}
impl TypedActionView for SearchCodebaseView {
type Action = SearchCodebaseViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SearchCodebaseViewAction::ToggleExpanded => {
self.collapsible.toggle_expanded();
ctx.notify();
}
SearchCodebaseViewAction::OpenLink {
link_range,
location,
} => {
if self
.detected_links_state
.link_at(location, link_range)
.is_some()
{
match self.detected_links_state.link_at(location, link_range) {
Some(DetectedLinkType::Url(link)) => {
ctx.open_url(link);
}
#[cfg(feature = "local_fs")]
Some(DetectedLinkType::FilePath {
absolute_path,
line_and_column_num,
}) => ctx.emit(SearchCodebaseViewEvent::OpenDetectedFilePath {
absolute_path: absolute_path.clone(),
line_and_column_num: *line_and_column_num,
}),
None => (),
}
}
}
SearchCodebaseViewAction::ChangedHoverOnLink {
link_range,
location,
is_hovering,
} => {
self.detected_links_state.update_hovered_link(
*is_hovering,
self.selection_handle.is_selecting(),
link_range,
location,
);
}
SearchCodebaseViewAction::OpenLinkTooltip {
link_range,
location,
} => {
if let Some(link_type) = self.detected_links_state.link_at(location, link_range) {
let rich_content_link: RichContentLink = match link_type {
DetectedLinkType::Url(link) => RichContentLink::Url(link.clone()),
#[cfg(feature = "local_fs")]
DetectedLinkType::FilePath {
absolute_path,
line_and_column_num,
} => RichContentLink::FilePath {
absolute_path: absolute_path.to_owned(),
line_and_column_num: *line_and_column_num,
target_override: None,
},
};
self.detected_links_state.link_location_open_tooltip = Some(LinkLocation {
link_range: link_range.clone(),
location: *location,
});
ctx.emit(SearchCodebaseViewEvent::OpenLinkTooltip { rich_content_link });
ctx.notify();
}
}
SearchCodebaseViewAction::SelectText => {
ctx.emit(SearchCodebaseViewEvent::TextSelected);
}
}
}
}
impl View for SearchCodebaseView {
fn ui_name() -> &'static str {
"SearchCodebaseView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
match &self.status {
Some(
AIActionStatus::Preprocessing
| AIActionStatus::Queued
| AIActionStatus::RunningAsync,
) => {
let loading_text = if let Some(repo_name) = &self.repo_name {
format!("Searching for \"{}\" in {}", self.search_query, repo_name)
} else {
format!("Searching codebase for \"{}\"", self.search_query)
};
let loading_icon = yellow_running_icon(appearance);
self.render_header(appearance, loading_text, loading_icon, app)
.with_agent_output_item_spacing(app)
.finish()
}
Some(AIActionStatus::Finished(result)) if result.result.is_cancelled() => {
let cancelled_text = if let Some(repo_name) = &self.repo_name {
format!(
"Search for \"{}\" in {} cancelled",
self.search_query, repo_name
)
} else {
format!("Search for \"{}\" cancelled", self.search_query)
};
let cancelled_icon = cancelled_icon(appearance);
self.render_header(appearance, cancelled_text, cancelled_icon, app)
.with_agent_output_item_spacing(app)
.finish()
}
Some(AIActionStatus::Finished(_)) => self
.render_finished(appearance, &self.file_contexts, app)
.with_agent_output_item_spacing(app)
.finish(),
_ => {
let text = if let Some(repo_name) = &self.repo_name {
format!(
"Searched codebase for \"{}\" in {}",
self.search_query, repo_name
)
} else {
format!("Searched codebase for \"{}\"", self.search_query)
};
self.render_simple_header(text, app)
.with_agent_output_item_spacing(app)
.finish()
}
}
}
}
@@ -0,0 +1,229 @@
use warp_core::ui::appearance::Appearance;
use warpui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use warpui::platform::Cursor;
use warpui::{AppContext, EventContext, SingletonEntity};
use super::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use super::inline_action_icons::icon_size;
use crate::ui_components::icons::Icon;
pub fn render_search_results_header(
title_text: String,
right_label_text: String,
is_expanded: bool,
mouse_state: warpui::elements::MouseStateHandle,
on_toggle: impl Fn(&mut EventContext) + 'static,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Left side: icon + title
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let search_icon = ConstrainedBox::new(
warpui::elements::Icon::new(
Icon::SearchSmall.into(),
appearance.theme().main_text_color(header_background),
)
.finish(),
)
.with_width(icon_size(app) - 4.)
.with_height(icon_size(app) - 4.)
.finish();
left_side.add_child(
Container::new(search_icon)
.with_margin_right(ICON_MARGIN)
.finish(),
);
let title = Text::new_inline(
title_text,
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(appearance.theme().main_text_color(header_background).into())
.finish();
left_side.add_child(Shrinkable::new(1.0, title).finish());
header_row.add_child(Shrinkable::new(1.0, left_side.finish()).finish());
// Right side: results label + chevron
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let right_label = Text::new_inline(
right_label_text,
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(appearance.theme().sub_text_color(header_background).into())
.finish();
right_side.add_child(Container::new(right_label).with_margin_right(8.).finish());
let chevron_icon = ConstrainedBox::new(
warpui::elements::Icon::new(
if is_expanded {
Icon::ChevronDown.into()
} else {
Icon::ChevronRight.into()
},
appearance.theme().sub_text_color(header_background),
)
.finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
right_side.add_child(chevron_icon);
let inner = Container::new(header_row.with_child(right_side.finish()).finish())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(if is_expanded {
CornerRadius::with_top(Radius::Pixels(8.))
} else {
CornerRadius::with_all(Radius::Pixels(8.))
})
.finish();
warpui::elements::Hoverable::new(mouse_state, |_| inner)
.on_click(move |ctx, _, _| {
on_toggle(ctx);
})
.with_cursor(Cursor::PointingHand)
.finish()
}
pub fn render_results_body_container(body: Box<dyn Element>, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
Container::new(body)
.with_background(appearance.theme().surface_1())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish()
}
pub fn render_loading_header(
text: String,
icon: warpui::elements::Icon,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
let icon_box = ConstrainedBox::new(icon.finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(ICON_MARGIN)
.finish(),
);
let title = Text::new_inline(
text,
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(appearance.theme().main_text_color(header_background).into())
.finish();
header_row.add_child(Shrinkable::new(1.0, title).finish());
Container::new(header_row.finish())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
pub fn render_expanded_layout(
header: Box<dyn Element>,
body_container: Box<dyn Element>,
) -> Box<dyn Element> {
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(body_container)
.finish()
}
/// A composable helper for managing collapsible search results state.
/// Parent views should store this and call `toggle_expanded()` on user interaction.
pub struct CollapsibleSearchResultsState {
pub is_expanded: bool,
pub mouse_state: MouseStateHandle,
}
impl CollapsibleSearchResultsState {
pub fn new() -> Self {
Self {
is_expanded: false,
mouse_state: MouseStateHandle::default(),
}
}
pub fn toggle_expanded(&mut self) {
self.is_expanded = !self.is_expanded;
}
}
impl Default for CollapsibleSearchResultsState {
fn default() -> Self {
Self::new()
}
}
/// Render a complete collapsible search results view with header and optional body.
/// This is a convenience wrapper around the individual rendering functions.
/// The on_toggle callback will be called when the user clicks the header.
pub fn render_collapsible_search_results<F>(
title_text: String,
results_count: usize,
results_label: &str,
state: &CollapsibleSearchResultsState,
body: Option<Box<dyn Element>>,
on_toggle: F,
app: &AppContext,
) -> Box<dyn Element>
where
F: Fn(&mut EventContext) + 'static,
{
let right_label = format!("{results_count} {results_label}");
let header = render_search_results_header(
title_text,
right_label,
state.is_expanded,
state.mouse_state.clone(),
on_toggle,
app,
);
if state.is_expanded {
if let Some(body_content) = body {
let body_container = render_results_body_container(body_content, app);
render_expanded_layout(header, body_container)
} else {
header
}
} else {
header
}
}
@@ -0,0 +1,432 @@
use std::sync::Arc;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use rand::{distributions::Alphanumeric, thread_rng, Rng as _};
use warp_core::{settings::ToggleableSetting, ui::appearance::Appearance};
use warpui::{
elements::{
Align, ConstrainedBox, Container, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
SavePosition, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Text,
},
platform::Cursor,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
ai::{
agent::{AIAgentActionId, AIIdentifiers},
predict::prompt_suggestions::{
ACCEPT_PROMPT_SUGGESTION_KEYBINDING, REJECT_PROMPT_SUGGESTION_KEYSTROKE,
},
},
send_telemetry_from_ctx,
server::telemetry::ToggleCodeSuggestionsSettingSource,
settings::AISettings,
ui_components::{blended_colors, icons::Icon},
view_components::{
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
compactible_action_button::{
render_compact_and_regular_button_rows, CompactibleActionButton,
MEDIUM_SIZE_SWITCH_THRESHOLD,
},
},
TelemetryEvent,
};
const ACCEPT_LABEL: &str = "Generate tests";
const CANCEL_LABEL: &str = "Dismiss";
#[derive(Debug, Clone)]
pub enum SuggestedUnitTestsEvent {
Accept,
Cancel,
Blur,
OpenSettings,
}
#[derive(Debug, Clone)]
pub enum SuggestedUnitTestsAction {
Accept,
Cancel,
ToggleSetting,
OpenSettings,
}
pub struct SuggestedUnitTestsView {
/// Client and server identifiers for the AI output associated with the suggested prompt.
identifiers: AIIdentifiers,
action_id: AIAgentActionId,
is_hidden: bool,
is_keybindings_hidden: bool,
should_show_speedbump: bool,
title: String,
description: String,
query: String,
accept_button: CompactibleActionButton,
cancel_button: CompactibleActionButton,
speedbump_mouse_state: MouseStateHandle,
ai_settings_link_highlight_index: HighlightedHyperlink,
/// A randomly-generated string prefix to ensure the [`SavePosition`]s in this view are unique.
position_id_prefix: String,
}
impl SuggestedUnitTestsView {
pub fn new(
identifiers: AIIdentifiers,
action_id: AIAgentActionId,
query: String,
title: String,
description: String,
should_show_speedbump: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
let accept_button = CompactibleActionButton::new(
ACCEPT_LABEL.to_string(),
Some(KeystrokeSource::Binding(
ACCEPT_PROMPT_SUGGESTION_KEYBINDING,
)),
ButtonSize::Small,
SuggestedUnitTestsAction::Accept,
Icon::Check,
Arc::new(PrimaryTheme),
ctx,
);
let cancel_button = CompactibleActionButton::new(
CANCEL_LABEL.to_string(),
Some(KeystrokeSource::Fixed(
REJECT_PROMPT_SUGGESTION_KEYSTROKE.clone(),
)),
ButtonSize::Small,
SuggestedUnitTestsAction::Cancel,
Icon::X,
Arc::new(NakedTheme),
ctx,
);
let random_str = thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect();
Self {
identifiers,
action_id,
is_hidden: false,
is_keybindings_hidden: false,
should_show_speedbump,
title,
description,
query,
accept_button,
cancel_button,
speedbump_mouse_state: Default::default(),
ai_settings_link_highlight_index: Default::default(),
position_id_prefix: random_str,
}
}
pub fn identifiers(&self) -> &AIIdentifiers {
&self.identifiers
}
pub fn action_id(&self) -> &AIAgentActionId {
&self.action_id
}
fn position_id_for_speedbump(&self) -> String {
format!(
"SuggestedUnitTestsView-speedbump-{}",
&self.position_id_prefix
)
}
pub fn is_hidden(&self) -> bool {
self.is_hidden
}
pub fn is_keybindings_hidden(&self) -> bool {
self.is_keybindings_hidden
}
pub fn query(&self) -> Option<String> {
(!self.query.is_empty()).then(|| self.query.to_string())
}
pub fn set_is_hidden(&mut self, is_hidden: bool) {
self.is_hidden = is_hidden;
}
pub fn hide_keybindings(&mut self, ctx: &mut ViewContext<Self>) {
self.is_keybindings_hidden = true;
self.accept_button.set_keybinding(None, ctx);
self.cancel_button.set_keybinding(None, ctx);
ctx.notify();
}
fn render_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(
warpui::elements::Icon::new(
Icon::Code2.into(),
appearance
.theme()
.main_text_color(appearance.theme().background())
.into_solid(),
)
.finish(),
)
.with_width(appearance.monospace_font_size())
.with_height(appearance.monospace_font_size())
.finish(),
)
.with_margin_right(8.)
.finish()
}
fn render_buttons(
&self,
appearance: &Appearance,
app: &AppContext,
) -> (Box<dyn Element>, Box<dyn Element>) {
{
render_compact_and_regular_button_rows(
vec![&self.cancel_button, &self.accept_button],
None,
appearance,
app,
)
}
}
fn render_header_contents(
&self,
buttons: Box<dyn Element>,
appearance: &Appearance,
) -> Box<dyn Element> {
let background = appearance.theme().background();
let mut col = Flex::column();
if !self.title.is_empty() {
let title = Text::new_inline(
self.title.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() + 4.,
)
.with_color(appearance.theme().main_text_color(background).into_solid())
.with_selectable(false)
.finish();
col.add_child(title);
}
if !self.description.is_empty() {
let description = Text::new_inline(
self.description.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(appearance.theme().sub_text_color(background).into_solid())
.with_selectable(false)
.finish();
col.add_child(Container::new(description).with_margin_top(2.).finish());
}
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Shrinkable::new(1., Expanded::new(1., col.finish()).finish()).finish())
.with_child(Align::new(buttons).right().finish())
.finish(),
)
.finish()
}
fn render_header(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let (regular_buttons, compact_buttons) = self.render_buttons(appearance, app);
let regular_header = self.render_header_contents(regular_buttons, appearance);
let compact_header = self.render_header_contents(compact_buttons, appearance);
let size_switch_threshold = MEDIUM_SIZE_SWITCH_THRESHOLD * appearance.monospace_ui_scalar();
SizeConstraintSwitch::new(
regular_header,
vec![(
SizeConstraintCondition::WidthLessThan(size_switch_threshold),
compact_header,
)],
)
.finish()
}
fn render_body(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let header = self.render_header(appearance, app);
let mut col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header);
if !self.query.is_empty() {
let query: Box<dyn Element> = Text::new(
self.query.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().background())
.into_solid(),
)
.with_selectable(true)
.finish();
col.add_child(Container::new(query).with_margin_top(6.).finish());
}
col.finish()
}
fn render_speedbump(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
let font_color = theme.sub_text_color(theme.background()).into_solid();
let font_family = appearance.ui_font_family();
let font_size = 12.;
let checked = AISettings::as_ref(app).is_code_suggestions_enabled(app);
let checkbox = appearance
.ui_builder()
.checkbox(self.speedbump_mouse_state.clone(), Some(font_size))
.check(!checked)
.with_style(UiComponentStyles {
font_color: Some(font_color),
font_size: Some(font_size),
..Default::default()
})
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(SuggestedUnitTestsAction::ToggleSetting);
})
.with_cursor(Cursor::PointingHand)
.finish();
let checkbox_text = appearance
.ui_builder()
.span("Don't show me suggested code banners again")
.with_style(UiComponentStyles {
font_color: Some(font_color),
font_size: Some(font_size),
padding: Some(Coords::default().left(4.)),
..Default::default()
})
.build()
.finish();
let formatted_text = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::hyperlink(
"Manage suggested code banner settings",
"Settings > AI",
),
])]),
font_size,
font_family,
font_family,
font_color,
self.ai_settings_link_highlight_index.clone(),
)
.with_hyperlink_font_color(blended_colors::accent_fg_strong(theme).into())
.register_default_click_handlers(|_, ctx, _| {
ctx.dispatch_typed_action(SuggestedUnitTestsAction::OpenSettings);
})
.finish();
let container = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children([checkbox, checkbox_text])
.finish(),
)
.with_child(formatted_text)
.finish(),
)
.with_padding_top(4.)
.finish();
SavePosition::new(container, &self.position_id_for_speedbump()).finish()
}
}
impl View for SuggestedUnitTestsView {
fn ui_name() -> &'static str {
"SuggestedUnitTestsView"
}
fn on_focus(&mut self, _focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
// We always want the focus to be in the input, not the view.
ctx.emit(SuggestedUnitTestsEvent::Blur);
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_child(self.render_icon(appearance))
.with_child(Expanded::new(1., self.render_body(appearance, app)).finish())
.finish();
if self.should_show_speedbump {
let speedbump = self.render_speedbump(appearance, app);
Flex::column()
.with_child(row)
.with_child(speedbump)
.finish()
} else {
row
}
}
}
impl Entity for SuggestedUnitTestsView {
type Event = SuggestedUnitTestsEvent;
}
impl TypedActionView for SuggestedUnitTestsView {
type Action = SuggestedUnitTestsAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SuggestedUnitTestsAction::Accept => ctx.emit(SuggestedUnitTestsEvent::Accept),
SuggestedUnitTestsAction::Cancel => ctx.emit(SuggestedUnitTestsEvent::Cancel),
SuggestedUnitTestsAction::ToggleSetting => {
let checked = AISettings::handle(ctx).update(ctx, |settings, ctx| {
settings
.code_suggestions_enabled_internal
.toggle_and_save_value(ctx)
});
ctx.notify();
if let Ok(checked) = checked {
send_telemetry_from_ctx!(
TelemetryEvent::ToggleCodeSuggestionsSetting {
source: ToggleCodeSuggestionsSettingSource::Speedbump,
is_code_suggestions_enabled: checked,
},
ctx
);
}
}
SuggestedUnitTestsAction::OpenSettings => {
ctx.emit(SuggestedUnitTestsEvent::OpenSettings)
}
}
}
}
@@ -0,0 +1,179 @@
use warp_core::ui::appearance::Appearance;
use warpui::elements::{Container, CrossAxisAlignment, Element, Flex, ParentElement, Text};
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use super::search_results_common::{
render_collapsible_search_results, CollapsibleSearchResultsState,
};
use crate::ai::agent::icons::yellow_running_icon;
use crate::ai::agent::WebFetchStatus;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
pub enum WebFetchViewEvent {}
#[derive(Clone, Debug)]
pub enum WebFetchViewAction {
ToggleExpanded,
}
pub struct WebFetchView {
pub status: WebFetchStatus,
pub collapsible: CollapsibleSearchResultsState,
}
impl WebFetchView {
pub fn new(urls: Vec<String>) -> Self {
Self {
status: WebFetchStatus::Fetching { urls },
collapsible: CollapsibleSearchResultsState::new(),
}
}
pub fn set_status(&mut self, status: &WebFetchStatus) {
self.status = status.clone();
}
fn render_loading(&self, urls: &[String], app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let loading_icon = yellow_running_icon(appearance);
let text = format!("Fetching {} web pages...", urls.len());
super::search_results_common::render_loading_header(text, loading_icon, app)
}
fn render_success(
&self,
pages: &[(String, String, bool)],
app: &AppContext,
) -> Box<dyn Element> {
let successful_count = pages.iter().filter(|(_, _, success)| *success).count();
let title_text = if successful_count == pages.len() {
format!("Fetched {} web pages", pages.len())
} else {
format!("Fetched {} of {} web pages", successful_count, pages.len())
};
let body = if self.collapsible.is_expanded {
Some(self.render_urls_list(pages, app))
} else {
None
};
render_collapsible_search_results(
title_text,
pages.len(),
"URLs",
&self.collapsible,
body,
|ctx| {
ctx.dispatch_typed_action(WebFetchViewAction::ToggleExpanded);
},
app,
)
}
fn render_urls_list(
&self,
pages: &[(String, String, bool)],
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (url, title, success) in pages {
let display_text = if title.is_empty() {
url.clone()
} else {
format!("{title} ({url})")
};
// Show failed URLs with a different indicator
let display_text = if *success {
display_text
} else {
format!("✗ {display_text}")
};
let text_color = if *success {
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
} else {
appearance
.theme()
.sub_text_color(appearance.theme().surface_1())
};
let url_text = Text::new_inline(
display_text,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(text_color.into())
.finish();
column.add_child(Container::new(url_text).with_vertical_padding(2.).finish());
}
if pages.is_empty() {
let no_results = Text::new_inline(
"No URLs fetched".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
.into(),
)
.finish();
column.add_child(no_results);
}
column.finish()
}
}
impl Entity for WebFetchView {
type Event = WebFetchViewEvent;
}
impl TypedActionView for WebFetchView {
type Action = WebFetchViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
WebFetchViewAction::ToggleExpanded => {
self.collapsible.toggle_expanded();
ctx.notify();
}
}
}
}
impl View for WebFetchView {
fn ui_name() -> &'static str {
"WebFetchView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
match &self.status {
WebFetchStatus::Fetching { urls } => self
.render_loading(urls, app)
.with_agent_output_item_spacing(app)
.finish(),
WebFetchStatus::Success { pages } => self
.render_success(pages, app)
.with_agent_output_item_spacing(app)
.finish(),
WebFetchStatus::Error => {
// Render as if fetch completed with no results
self.render_success(&[], app)
.with_agent_output_item_spacing(app)
.finish()
}
}
}
}
@@ -0,0 +1,170 @@
use warp_core::ui::appearance::Appearance;
use warpui::elements::{Container, CrossAxisAlignment, Element, Flex, ParentElement, Text};
use warpui::{AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext};
use super::search_results_common::{
render_collapsible_search_results, CollapsibleSearchResultsState,
};
use crate::ai::agent::icons::yellow_running_icon;
use crate::ai::agent::WebSearchStatus;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
pub enum WebSearchViewEvent {}
#[derive(Clone, Debug)]
pub enum WebSearchViewAction {
ToggleExpanded,
}
pub struct WebSearchView {
pub status: WebSearchStatus,
pub collapsible: CollapsibleSearchResultsState,
}
impl WebSearchView {
pub fn new(query: String) -> Self {
Self {
status: WebSearchStatus::Searching {
query: if query.is_empty() { None } else { Some(query) },
},
collapsible: CollapsibleSearchResultsState::new(),
}
}
pub fn set_status(&mut self, status: &WebSearchStatus) {
self.status = status.clone();
}
fn render_loading(&self, query: &Option<String>, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let loading_icon = yellow_running_icon(appearance);
let text = if let Some(q) = query {
format!("Searching the web for \"{q}\"")
} else {
"Searching the web".to_string()
};
super::search_results_common::render_loading_header(text, loading_icon, app)
}
fn render_success(
&self,
query: &str,
pages: &[(String, String)],
app: &AppContext,
) -> Box<dyn Element> {
let title_text = if query.is_empty() {
"Searched the web".to_string()
} else {
format!("Searched the web for \"{query}\"")
};
let body = if self.collapsible.is_expanded {
Some(self.render_urls_list(pages, app))
} else {
None
};
render_collapsible_search_results(
title_text,
pages.len(),
"URLs",
&self.collapsible,
body,
|ctx| {
ctx.dispatch_typed_action(WebSearchViewAction::ToggleExpanded);
},
app,
)
}
fn render_urls_list(&self, pages: &[(String, String)], app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (url, title) in pages {
let display_text = if title.is_empty() {
url.clone()
} else {
format!("{title} ({url})")
};
let url_text = Text::new_inline(
display_text,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.0,
)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
.into(),
)
.finish();
column.add_child(Container::new(url_text).with_vertical_padding(2.).finish());
}
if pages.is_empty() {
let no_results = Text::new_inline(
"No URLs found".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
appearance
.theme()
.main_text_color(appearance.theme().surface_1())
.into(),
)
.finish();
column.add_child(no_results);
}
column.finish()
}
}
impl Entity for WebSearchView {
type Event = WebSearchViewEvent;
}
impl TypedActionView for WebSearchView {
type Action = WebSearchViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
WebSearchViewAction::ToggleExpanded => {
self.collapsible.toggle_expanded();
ctx.notify();
}
}
}
}
impl View for WebSearchView {
fn ui_name() -> &'static str {
"WebSearchView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
match &self.status {
WebSearchStatus::Searching { query } => self
.render_loading(query, app)
.with_agent_output_item_spacing(app)
.finish(),
WebSearchStatus::Success { query, pages } => self
.render_success(query, pages, app)
.with_agent_output_item_spacing(app)
.finish(),
WebSearchStatus::Error { query } => {
// For now, render as if search completed with no results
// TODO(advait): Add proper error rendering
self.render_success(query, &[], app)
.with_agent_output_item_spacing(app)
.finish()
}
}
}
}