Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
use crate::context_chips::{
|
||||
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
|
||||
git_line_changes_from_chips,
|
||||
prompt_type::PromptType,
|
||||
ChipResult,
|
||||
};
|
||||
use warpui::{ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use super::{AgentInputFooter, AgentInputFooterEvent};
|
||||
|
||||
impl AgentInputFooter {
|
||||
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
|
||||
///
|
||||
/// This is basically cargo-culted from the equivalent logic in `PromptDisplay`, pared down to
|
||||
/// only the chip types that we care about in the AgentView input.
|
||||
fn check_if_chip_values_have_changed(
|
||||
existing_chips: &[ViewHandle<DisplayChip>],
|
||||
new_chips: &[ChipResult],
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
existing_chips.len() != new_chips.len()
|
||||
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
|
||||
let existing_chip = &existing_chips[i];
|
||||
existing_chip.read(ctx, |chip, _| {
|
||||
chip.text()
|
||||
!= chip_result
|
||||
.value()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
|| chip.chip_kind() != chip_result.kind()
|
||||
// For parity with PromptDisplay: compare the first on-click value only.
|
||||
|| chip.first_on_click_value() != chip_result.on_click_values().first()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn create_display_chips(
|
||||
&self,
|
||||
new_chips: &[ChipResult],
|
||||
git_line_changes_info: Option<GitLineChanges>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<ViewHandle<DisplayChip>> {
|
||||
let mut display_chips = Vec::with_capacity(new_chips.len());
|
||||
let mut new_chips = new_chips.iter().peekable();
|
||||
while let Some(chip_result) = new_chips.next() {
|
||||
let next_chip_kind = new_chips
|
||||
.peek()
|
||||
.map(|chip_result| chip_result.kind().clone());
|
||||
|
||||
let view_handle = ctx.add_typed_action_view(|ctx| {
|
||||
let config = self.display_chip_config.clone();
|
||||
let mut chip = DisplayChip::new_for_agent_view(
|
||||
chip_result.clone(),
|
||||
next_chip_kind,
|
||||
config,
|
||||
ctx,
|
||||
);
|
||||
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
|
||||
chip.update_session_context(self.display_chip_config.session_context.clone(), ctx);
|
||||
chip
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&view_handle, move |_, _, event, ctx| match event {
|
||||
PromptDisplayChipEvent::ToggleMenu { open } => {
|
||||
ctx.emit(AgentInputFooterEvent::ToggledChipMenu { open: *open });
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::TryExecuteCommand(cmd) => {
|
||||
ctx.emit(AgentInputFooterEvent::TryExecuteChipCommand(cmd.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenCodeReview => {
|
||||
ctx.emit(AgentInputFooterEvent::OpenCodeReview);
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenAIDocument {
|
||||
document_id,
|
||||
document_version,
|
||||
} => {
|
||||
ctx.emit(AgentInputFooterEvent::OpenAIDocument {
|
||||
document_id: *document_id,
|
||||
document_version: *document_version,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
display_chips.push(view_handle);
|
||||
}
|
||||
|
||||
display_chips
|
||||
}
|
||||
|
||||
fn update_existing_display_chips(
|
||||
display_chips: &[ViewHandle<DisplayChip>],
|
||||
git_line_changes_info: Option<GitLineChanges>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
for chip_view in display_chips {
|
||||
chip_view.update(ctx, |chip, ctx| {
|
||||
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the display chip views based on a change to the underlying metadata that drives the
|
||||
/// prompt, modeled in `PromptType`.
|
||||
///
|
||||
/// This is basically cargo-culted from the equivalent logic in `PromptDisplay`, pared down to
|
||||
/// only the chip types that we care about in the AgentView input.
|
||||
///
|
||||
/// The whole context chip/UDI chip/prompt layer is in need of a big refactor; once the
|
||||
/// `FeatureFlag::AgentView` is retired we'll have an opportunity to do a refactor with a smaller
|
||||
/// surface area, presumably because we'll be able to first delete a lot of the affected logic
|
||||
/// which the UDI and legacy inputs depend on.
|
||||
pub(super) fn update_display_chips(
|
||||
&mut self,
|
||||
model: &ModelHandle<PromptType>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let new_left_chips = model
|
||||
.as_ref(ctx)
|
||||
.agent_view_left_chips(ctx)
|
||||
.into_iter()
|
||||
.filter(|chip_result| chip_result.value().is_some())
|
||||
.collect::<Vec<ChipResult>>();
|
||||
let new_right_chips = model
|
||||
.as_ref(ctx)
|
||||
.agent_view_right_chips(ctx)
|
||||
.into_iter()
|
||||
.filter(|chip_result| chip_result.value().is_some())
|
||||
.collect::<Vec<ChipResult>>();
|
||||
let new_chips = model
|
||||
.as_ref(ctx)
|
||||
.agent_view_chips(ctx)
|
||||
.into_iter()
|
||||
.filter(|chip| chip.value().is_some())
|
||||
.collect::<Vec<ChipResult>>();
|
||||
let git_line_changes_info = git_line_changes_from_chips(&new_chips);
|
||||
|
||||
let should_update_left =
|
||||
Self::check_if_chip_values_have_changed(&self.left_display_chips, &new_left_chips, ctx);
|
||||
let should_update_right = Self::check_if_chip_values_have_changed(
|
||||
&self.right_display_chips,
|
||||
&new_right_chips,
|
||||
ctx,
|
||||
);
|
||||
|
||||
if should_update_left {
|
||||
self.left_display_chips =
|
||||
self.create_display_chips(&new_left_chips, git_line_changes_info.clone(), ctx);
|
||||
} else {
|
||||
Self::update_existing_display_chips(
|
||||
&self.left_display_chips,
|
||||
git_line_changes_info.clone(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
if should_update_right {
|
||||
self.right_display_chips =
|
||||
self.create_display_chips(&new_right_chips, git_line_changes_info.clone(), ctx);
|
||||
} else {
|
||||
Self::update_existing_display_chips(
|
||||
&self.right_display_chips,
|
||||
git_line_changes_info.clone(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Build display chips for the CLI agent footer separately.
|
||||
// The CLI selection may include chips not present in the agent view selection.
|
||||
let new_cli_chips = model
|
||||
.as_ref(ctx)
|
||||
.cli_agent_chips(ctx)
|
||||
.into_iter()
|
||||
.filter(|chip_result| chip_result.value().is_some())
|
||||
.collect::<Vec<ChipResult>>();
|
||||
let should_update_cli =
|
||||
Self::check_if_chip_values_have_changed(&self.cli_display_chips, &new_cli_chips, ctx);
|
||||
if should_update_cli {
|
||||
self.cli_display_chips =
|
||||
self.create_display_chips(&new_cli_chips, git_line_changes_info.clone(), ctx);
|
||||
} else {
|
||||
Self::update_existing_display_chips(
|
||||
&self.cli_display_chips,
|
||||
git_line_changes_info,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Modal for customizing the agent input footer chip layout.
|
||||
//!
|
||||
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
|
||||
//! drag/drop chips between left, right, and unused banks.
|
||||
|
||||
use warpui::keymap::FixedBinding;
|
||||
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::chip_configurator::{
|
||||
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
|
||||
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
|
||||
ChipEditorSectionsConfig,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::terminal::session_settings::{
|
||||
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
|
||||
SessionSettingsChangedEvent, ToolbarChipSelection,
|
||||
};
|
||||
use crate::Appearance;
|
||||
|
||||
use settings::Setting as _;
|
||||
|
||||
use super::toolbar_item::AgentToolbarItemKind;
|
||||
|
||||
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
|
||||
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
|
||||
|
||||
/// Controls which set of items and settings the editor modal operates on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AgentToolbarEditorMode {
|
||||
#[default]
|
||||
AgentView,
|
||||
CLIAgent,
|
||||
}
|
||||
pub enum AgentToolbarEditorEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct AgentToolbarEditorModal {
|
||||
mouse_handles: ChipEditorMouseHandles,
|
||||
chip_configurator: ChipConfigurator,
|
||||
mode: AgentToolbarEditorMode,
|
||||
is_dirty: bool,
|
||||
}
|
||||
|
||||
pub struct AgentToolbarInlineEditor {
|
||||
mouse_handles: ChipEditorMouseHandles,
|
||||
chip_configurator: ChipConfigurator,
|
||||
mode: AgentToolbarEditorMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum AgentToolbarEditorAction {
|
||||
Cancel,
|
||||
Save,
|
||||
Chip(ChipConfiguratorAction),
|
||||
ResetDefault,
|
||||
/// Dummy action used as on_click for chip bank clicks (no-op).
|
||||
Activate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum AgentToolbarInlineEditorAction {
|
||||
Chip(ChipConfiguratorAction),
|
||||
ResetDefault,
|
||||
/// Dummy action used as on_click for chip bank clicks (no-op).
|
||||
Activate,
|
||||
}
|
||||
|
||||
fn open_toolbar_items_from_settings<V: View>(
|
||||
chip_configurator: &mut ChipConfigurator,
|
||||
mode: AgentToolbarEditorMode,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let session_settings = SessionSettings::as_ref(ctx);
|
||||
let (current_left, current_right, available) = match mode {
|
||||
AgentToolbarEditorMode::AgentView => {
|
||||
let selection = session_settings.agent_footer_chip_selection.clone();
|
||||
(
|
||||
selection.left_items(),
|
||||
selection.right_items(),
|
||||
AgentToolbarItemKind::all_available(),
|
||||
)
|
||||
}
|
||||
AgentToolbarEditorMode::CLIAgent => {
|
||||
let selection = session_settings.cli_agent_footer_chip_selection.clone();
|
||||
(
|
||||
selection.left_items(),
|
||||
selection.right_items(),
|
||||
AgentToolbarItemKind::all_available_for_cli_input(),
|
||||
)
|
||||
}
|
||||
};
|
||||
chip_configurator.open_left_right_zones_with_items(
|
||||
current_left,
|
||||
current_right,
|
||||
available,
|
||||
appearance,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_default_toolbar_items<V: View>(
|
||||
chip_configurator: &mut ChipConfigurator,
|
||||
mode: AgentToolbarEditorMode,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
|
||||
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
|
||||
}
|
||||
|
||||
fn is_toolbar_editor_at_defaults(
|
||||
mode: AgentToolbarEditorMode,
|
||||
chip_configurator: &ChipConfigurator,
|
||||
) -> bool {
|
||||
let left = chip_configurator.left_item_kinds();
|
||||
let right = chip_configurator.right_item_kinds();
|
||||
toolbar_items_match_defaults(mode, &left, &right)
|
||||
}
|
||||
|
||||
fn toolbar_items_match_defaults(
|
||||
mode: AgentToolbarEditorMode,
|
||||
left: &[AgentToolbarItemKind],
|
||||
right: &[AgentToolbarItemKind],
|
||||
) -> bool {
|
||||
let (default_left, default_right, _) = AgentToolbarItemKind::defaults_for_mode(mode);
|
||||
default_left.as_slice() == left && default_right.as_slice() == right
|
||||
}
|
||||
|
||||
impl AgentToolbarInlineEditor {
|
||||
pub fn new(mode: AgentToolbarEditorMode, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let mut editor = Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
mode,
|
||||
};
|
||||
editor.reset_from_settings(ctx);
|
||||
|
||||
ctx.subscribe_to_model(&SessionSettings::handle(ctx), |me, _, event, ctx| {
|
||||
let should_refresh = matches!(
|
||||
(me.mode, event),
|
||||
(
|
||||
AgentToolbarEditorMode::AgentView,
|
||||
SessionSettingsChangedEvent::AgentToolbarChipSelectionSetting { .. },
|
||||
) | (
|
||||
AgentToolbarEditorMode::CLIAgent,
|
||||
SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. },
|
||||
)
|
||||
);
|
||||
|
||||
if should_refresh && me.chip_configurator.current_dragging_state.is_none() {
|
||||
me.reset_from_settings(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
fn reset_from_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
open_toolbar_items_from_settings(&mut self.chip_configurator, self.mode, ctx);
|
||||
}
|
||||
|
||||
fn save_current_selection(&self, ctx: &mut ViewContext<Self>) {
|
||||
let left = self.chip_configurator.left_item_kinds();
|
||||
let right = self.chip_configurator.right_item_kinds();
|
||||
save_toolbar_selection(self.mode, left, right, ctx);
|
||||
}
|
||||
|
||||
fn is_at_defaults(&self) -> bool {
|
||||
is_toolbar_editor_at_defaults(self.mode, &self.chip_configurator)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AgentToolbarInlineEditor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentToolbarInlineEditor {
|
||||
type Action = AgentToolbarInlineEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Self::Action::Chip(chip_action) => {
|
||||
let should_save = self.chip_configurator.handle_action(chip_action, ctx);
|
||||
if should_save {
|
||||
self.save_current_selection(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::ResetDefault => {
|
||||
open_default_toolbar_items(&mut self.chip_configurator, self.mode, ctx);
|
||||
self.save_current_selection(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::Activate => {
|
||||
// no-op — used as the on_click for chip bank items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AgentToolbarInlineEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"AgentToolbarInlineEditor"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
render_chip_editor_sections(
|
||||
&self.chip_configurator,
|
||||
ChipEditorSectionsConfig {
|
||||
available_section_label: "Available chips",
|
||||
is_at_defaults: self.is_at_defaults(),
|
||||
reset_action: AgentToolbarInlineEditorAction::ResetDefault,
|
||||
activate_action: AgentToolbarInlineEditorAction::Activate,
|
||||
chip_action_wrapper: AgentToolbarInlineEditorAction::Chip,
|
||||
mouse_handles: &self.mouse_handles,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
AgentToolbarEditorAction::Cancel,
|
||||
id!(AgentToolbarEditorModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
fn save_toolbar_selection<V: View>(
|
||||
mode: AgentToolbarEditorMode,
|
||||
left: Vec<AgentToolbarItemKind>,
|
||||
right: Vec<AgentToolbarItemKind>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let is_default = toolbar_items_match_defaults(mode, &left, &right);
|
||||
match mode {
|
||||
AgentToolbarEditorMode::AgentView => {
|
||||
let selection = if is_default {
|
||||
AgentToolbarChipSelection::Default
|
||||
} else {
|
||||
AgentToolbarChipSelection::Custom { left, right }
|
||||
};
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.agent_footer_chip_selection
|
||||
.set_value(selection, ctx));
|
||||
});
|
||||
}
|
||||
AgentToolbarEditorMode::CLIAgent => {
|
||||
let selection = if is_default {
|
||||
CLIAgentToolbarChipSelection::Default
|
||||
} else {
|
||||
CLIAgentToolbarChipSelection::Custom { left, right }
|
||||
};
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.cli_agent_footer_chip_selection
|
||||
.set_value(selection, ctx));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentToolbarEditorModal {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
mode: AgentToolbarEditorMode::default(),
|
||||
is_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self, mode: AgentToolbarEditorMode, ctx: &mut ViewContext<Self>) {
|
||||
self.reset();
|
||||
self.mode = mode;
|
||||
open_toolbar_items_from_settings(&mut self.chip_configurator, mode, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn save_to_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_dirty {
|
||||
return;
|
||||
}
|
||||
|
||||
let left = self.chip_configurator.left_item_kinds();
|
||||
let right = self.chip_configurator.right_item_kinds();
|
||||
save_toolbar_selection(self.mode, left, right, ctx);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.chip_configurator.reset();
|
||||
self.is_dirty = false;
|
||||
}
|
||||
|
||||
fn modal_title(&self) -> &'static str {
|
||||
match self.mode {
|
||||
AgentToolbarEditorMode::AgentView => AGENT_MODAL_TITLE,
|
||||
AgentToolbarEditorMode::CLIAgent => CLI_MODAL_TITLE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AgentToolbarEditorModal {
|
||||
type Event = AgentToolbarEditorEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentToolbarEditorModal {
|
||||
type Action = AgentToolbarEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Self::Action::Cancel => {
|
||||
self.reset();
|
||||
ctx.emit(AgentToolbarEditorEvent::Close);
|
||||
}
|
||||
Self::Action::Save => {
|
||||
self.save_to_settings(ctx);
|
||||
ctx.emit(AgentToolbarEditorEvent::Close);
|
||||
}
|
||||
Self::Action::Chip(chip_action) => {
|
||||
let mutated = self.chip_configurator.handle_action(chip_action, ctx);
|
||||
if mutated {
|
||||
self.is_dirty = true;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::ResetDefault => {
|
||||
self.is_dirty = true;
|
||||
open_default_toolbar_items(&mut self.chip_configurator, self.mode, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::Activate => {
|
||||
// no-op — used as the on_click for chip bank items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentToolbarEditorModal {
|
||||
fn is_at_defaults(&self) -> bool {
|
||||
is_toolbar_editor_at_defaults(self.mode, &self.chip_configurator)
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AgentToolbarEditorModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"AgentToolbarEditorModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
render_chip_editor_modal(
|
||||
&self.chip_configurator,
|
||||
ChipEditorModalConfig {
|
||||
title: self.modal_title(),
|
||||
available_section_label: "Available chips",
|
||||
is_at_defaults: self.is_at_defaults(),
|
||||
is_dirty: self.is_dirty,
|
||||
cancel_action: AgentToolbarEditorAction::Cancel,
|
||||
save_action: AgentToolbarEditorAction::Save,
|
||||
reset_action: AgentToolbarEditorAction::ResetDefault,
|
||||
activate_action: AgentToolbarEditorAction::Activate,
|
||||
chip_action_wrapper: AgentToolbarEditorAction::Chip,
|
||||
mouse_handles: &self.mouse_handles,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::color::blend::Blend;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
|
||||
ai::{
|
||||
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
|
||||
},
|
||||
appearance::Appearance,
|
||||
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
|
||||
context_chips::display_menu::{
|
||||
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
|
||||
},
|
||||
report_if_error,
|
||||
server::ids::SyncId,
|
||||
terminal::input::{MenuPositioning, MenuPositioningProvider},
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
|
||||
};
|
||||
|
||||
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
|
||||
|
||||
/// A selector component for choosing an ambient agent environment.
|
||||
pub struct EnvironmentSelector {
|
||||
button: ViewHandle<ActionButton>,
|
||||
dropdown: ViewHandle<DisplayChipMenu>,
|
||||
is_menu_open: bool,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
}
|
||||
|
||||
pub enum EnvironmentSelectorEvent {
|
||||
MenuVisibilityChanged { open: bool },
|
||||
OpenEnvironmentManagementPane,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EnvironmentSelectorAction {
|
||||
ToggleMenu,
|
||||
}
|
||||
|
||||
/// Menu item for an environment in the selector.
|
||||
#[derive(Debug, Clone)]
|
||||
struct EnvironmentMenuItem {
|
||||
id: SyncId,
|
||||
name: String,
|
||||
is_selected: bool,
|
||||
}
|
||||
|
||||
const ENV_MENU_CHECK_ICON_SIZE: f32 = 16.;
|
||||
|
||||
impl GenericMenuItem for EnvironmentMenuItem {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn icon(&self, _app: &AppContext) -> Option<Icon> {
|
||||
None
|
||||
}
|
||||
|
||||
fn action_data(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
|
||||
fn right_side_element(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if !self.is_selected {
|
||||
return None;
|
||||
}
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let color = theme.main_text_color(theme.surface_2()).into_solid();
|
||||
Some(
|
||||
ConstrainedBox::new(Icon::Check.to_warpui_icon(Fill::Solid(color)).finish())
|
||||
.with_width(ENV_MENU_CHECK_ICON_SIZE)
|
||||
.with_height(ENV_MENU_CHECK_ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu item for the "New Environment" footer option.
|
||||
#[derive(Debug, Clone)]
|
||||
struct NewEnvironmentMenuItem;
|
||||
|
||||
impl GenericMenuItem for NewEnvironmentMenuItem {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
"New environment".to_string()
|
||||
}
|
||||
|
||||
fn icon(&self, _app: &AppContext) -> Option<Icon> {
|
||||
Some(Icon::Plus)
|
||||
}
|
||||
|
||||
fn action_data(&self) -> String {
|
||||
"new_environment".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
|
||||
environments.sort_by(|a, b| {
|
||||
// Sort by last-used timestamp descending (most recent first), then by display name ascending
|
||||
b.metadata
|
||||
.last_task_run_ts
|
||||
.cmp(&a.metadata.last_task_run_ts)
|
||||
.then_with(|| {
|
||||
a.model()
|
||||
.string_model
|
||||
.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.model().string_model.name.to_lowercase())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
impl EnvironmentSelector {
|
||||
pub fn new(
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("", AgentInputButtonTheme)
|
||||
.with_icon(Icon::Globe4)
|
||||
.with_tooltip("Choose an environment")
|
||||
.with_size(ButtonSize::AgentInputButton)
|
||||
.with_disabled_theme(DisabledTheme)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(EnvironmentSelectorAction::ToggleMenu);
|
||||
})
|
||||
});
|
||||
|
||||
let dropdown = ctx.add_typed_action_view(move |ctx| {
|
||||
DisplayChipMenu::new(
|
||||
Vec::<EnvironmentMenuItem>::new(),
|
||||
Some(FixedFooter::new(Arc::new(NewEnvironmentMenuItem))),
|
||||
ChipMenuType::Environments,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&dropdown, |me, _, event, ctx| match event {
|
||||
PromptDisplayMenuEvent::MenuAction(generic_event) => {
|
||||
// Check if this is the "New Environment" footer action
|
||||
if generic_event
|
||||
.action_item
|
||||
.as_any()
|
||||
.downcast_ref::<NewEnvironmentMenuItem>()
|
||||
.is_some()
|
||||
{
|
||||
send_telemetry_from_ctx!(
|
||||
CloudAgentTelemetryEvent::OpenedEnvironmentManagementPane,
|
||||
ctx
|
||||
);
|
||||
me.set_menu_visibility(false, ctx);
|
||||
ctx.emit(EnvironmentSelectorEvent::OpenEnvironmentManagementPane);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, it's an environment selection.
|
||||
if let Some(env_item) = generic_event
|
||||
.action_item
|
||||
.as_any()
|
||||
.downcast_ref::<EnvironmentMenuItem>()
|
||||
{
|
||||
send_telemetry_from_ctx!(
|
||||
CloudAgentTelemetryEvent::EnvironmentSelected {
|
||||
environment_id: env_item.id.into_server(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
if me.is_configuring(ctx) {
|
||||
me.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_item.id), ctx);
|
||||
});
|
||||
// Persist the selection to settings for next time.
|
||||
me.save_selected_environment_to_settings(env_item.id, ctx);
|
||||
}
|
||||
me.set_menu_visibility(false, ctx);
|
||||
}
|
||||
}
|
||||
PromptDisplayMenuEvent::CloseMenu => {
|
||||
me.set_menu_visibility(false, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to CloudModel to refresh when environments are added/removed.
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), |me, _, _, ctx| {
|
||||
me.ensure_default_selection(ctx);
|
||||
me.refresh_menu(ctx);
|
||||
me.refresh_button(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
|
||||
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
let mut me = Self {
|
||||
button,
|
||||
dropdown,
|
||||
is_menu_open: false,
|
||||
menu_positioning_provider,
|
||||
ambient_agent_model,
|
||||
};
|
||||
me.refresh_menu(ctx);
|
||||
me.refresh_button(ctx);
|
||||
me.ensure_default_selection(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
pub fn is_menu_open(&self) -> bool {
|
||||
self.is_menu_open
|
||||
}
|
||||
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
self.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.is_configuring_ambient_agent()
|
||||
}
|
||||
|
||||
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
||||
if self.is_menu_open == is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.is_menu_open = is_open;
|
||||
if is_open {
|
||||
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
|
||||
ctx.focus(&self.dropdown);
|
||||
}
|
||||
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Ensures a default environment is selected if none is currently selected.
|
||||
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let current_selection = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id();
|
||||
if current_selection.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, try to restore the user's last selected environment from settings.
|
||||
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
|
||||
// Verify the environment still exists.
|
||||
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_id), ctx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to auto-selecting the most recently used environment.
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
if let Some(first_env) = environments.first() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(first_env.id), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the last selected environment ID from settings.
|
||||
fn get_saved_environment_from_settings(&self, ctx: &ViewContext<Self>) -> Option<SyncId> {
|
||||
*CloudAgentSettings::as_ref(ctx)
|
||||
.last_selected_environment_id
|
||||
.value()
|
||||
}
|
||||
|
||||
/// Saves the selected environment ID to settings.
|
||||
fn save_selected_environment_to_settings(&self, env_id: SyncId, ctx: &mut ViewContext<Self>) {
|
||||
CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.last_selected_environment_id
|
||||
.set_value(Some(env_id), ctx));
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
|
||||
let selected_id = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.cloned();
|
||||
|
||||
let menu_items: Vec<EnvironmentMenuItem> = environments
|
||||
.iter()
|
||||
.map(|env| {
|
||||
let is_selected = selected_id.as_ref() == Some(&env.id);
|
||||
EnvironmentMenuItem {
|
||||
id: env.id,
|
||||
name: env.model().string_model.display_name(),
|
||||
is_selected,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.dropdown.update(ctx, |menu, ctx| {
|
||||
menu.update_menu_items(menu_items, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let label = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
|
||||
.map(|env| env.model().string_model.display_name())
|
||||
.unwrap_or_else(|| "New environment".to_string());
|
||||
|
||||
let is_configuring = self.is_configuring(ctx);
|
||||
|
||||
self.button.update(ctx, |button, ctx| {
|
||||
button.set_label(label, ctx);
|
||||
button.set_tooltip(
|
||||
if is_configuring {
|
||||
Some("Choose an environment")
|
||||
} else {
|
||||
Some("Agent environment")
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
button.set_disabled(!is_configuring, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn get_menu_positioning(&self, app: &AppContext) -> OffsetPositioning {
|
||||
match self.menu_positioning_provider.menu_position(app) {
|
||||
MenuPositioning::BelowInputBox => OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
MenuPositioning::AboveInputBox => OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -4.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for EnvironmentSelector {
|
||||
type Action = EnvironmentSelectorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
EnvironmentSelectorAction::ToggleMenu => {
|
||||
if self.is_configuring(ctx) {
|
||||
self.set_menu_visibility(!self.is_menu_open, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for EnvironmentSelector {
|
||||
fn ui_name() -> &'static str {
|
||||
"EnvironmentSelector"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(ChildView::new(&self.button).finish());
|
||||
|
||||
if self.is_menu_open {
|
||||
let menu = ChildView::new(&self.dropdown).finish();
|
||||
let positioning = self.get_menu_positioning(app);
|
||||
stack.add_positioned_overlay_child(menu, positioning);
|
||||
}
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for EnvironmentSelector {
|
||||
type Event = EnvironmentSelectorEvent;
|
||||
}
|
||||
|
||||
struct DisabledTheme;
|
||||
|
||||
impl ActionButtonTheme for DisabledTheme {
|
||||
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
||||
AgentInputButtonTheme.background(hovered, appearance)
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
// `background` may be a translucent overlay fill; compute disabled text color against an
|
||||
// effective solid background to avoid washing out the label.
|
||||
let base_bg = appearance.theme().surface_1();
|
||||
let effective_bg = match background {
|
||||
Some(overlay) => base_bg.blend(&overlay),
|
||||
None => base_bg,
|
||||
};
|
||||
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(effective_bg)
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
|
||||
AgentInputButtonTheme.border(appearance)
|
||||
}
|
||||
|
||||
fn should_opt_out_of_contrast_adjustment(&self) -> bool {
|
||||
AgentInputButtonTheme.should_opt_out_of_contrast_adjustment()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
use super::editor::AgentToolbarEditorMode;
|
||||
|
||||
/// Declares which footer(s) a toolbar item is available in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToolbarAvailability {
|
||||
AgentViewOnly,
|
||||
CLIAgentOnly,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl ToolbarAvailability {
|
||||
pub fn is_available_for_agent_view(self) -> bool {
|
||||
matches!(self, Self::AgentViewOnly | Self::Both)
|
||||
}
|
||||
|
||||
pub fn is_available_for_cli(self) -> bool {
|
||||
matches!(self, Self::CLIAgentOnly | Self::Both)
|
||||
}
|
||||
}
|
||||
|
||||
/// A configurable item
|
||||
///
|
||||
/// This unifies context-chip data displays with interactive control buttons so
|
||||
/// they can all be arranged through the same drag-and-drop editor.
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "An item that can appear in the agent toolbar.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum AgentToolbarItemKind {
|
||||
#[schemars(description = "A prompt context chip.")]
|
||||
ContextChip(ContextChipKind),
|
||||
// Agent view only
|
||||
ModelSelector,
|
||||
NLDToggle,
|
||||
ContextWindowUsage,
|
||||
|
||||
// CLI agent only
|
||||
FileExplorer,
|
||||
RichInput,
|
||||
|
||||
// Both
|
||||
VoiceInput,
|
||||
// Renamed from ImageAttach; alias preserves existing user toolbar configs.
|
||||
#[serde(alias = "ImageAttach")]
|
||||
FileAttach,
|
||||
ShareSession,
|
||||
|
||||
// CLI agent only – opens settings to the Coding Agents section.
|
||||
Settings,
|
||||
|
||||
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
|
||||
FastForwardToggle,
|
||||
}
|
||||
|
||||
impl AgentToolbarItemKind {
|
||||
pub fn available_in(&self) -> ToolbarAvailability {
|
||||
match self {
|
||||
Self::ContextChip(_) | Self::VoiceInput | Self::FileAttach | Self::ShareSession => {
|
||||
ToolbarAvailability::Both
|
||||
}
|
||||
Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
|
||||
Self::FileExplorer | Self::RichInput | Self::Settings => {
|
||||
ToolbarAvailability::CLIAgentOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should be visible to session viewers.
|
||||
/// Items that control host settings or initiate actions on the host's
|
||||
/// behalf are hidden from viewers.
|
||||
pub fn available_to_session_viewer(
|
||||
&self,
|
||||
status: &SharedSessionStatus,
|
||||
is_cloud_mode: bool,
|
||||
) -> bool {
|
||||
match self {
|
||||
Self::Settings | Self::ShareSession | Self::FileExplorer => !status.is_viewer(),
|
||||
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
|
||||
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
|
||||
Self::ContextChip(_)
|
||||
| Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::RichInput
|
||||
| Self::VoiceInput => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ContextChip(_) => "Context Chip",
|
||||
Self::ModelSelector => "Model Selector",
|
||||
Self::NLDToggle => "Autodetection",
|
||||
Self::VoiceInput => "Voice Input",
|
||||
Self::FileAttach => "Attach File",
|
||||
Self::ContextWindowUsage => "Context Usage",
|
||||
Self::FileExplorer => "File Explorer",
|
||||
Self::RichInput => "Rich Input",
|
||||
Self::ShareSession => "/remote-control",
|
||||
Self::Settings => "Settings",
|
||||
Self::FastForwardToggle => "Fast Forward",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> Option<Icon> {
|
||||
match self {
|
||||
Self::ContextChip(kind) => kind.udi_icon(),
|
||||
Self::ModelSelector => Some(Icon::Oz),
|
||||
Self::NLDToggle => Some(Icon::NLD),
|
||||
Self::VoiceInput => Some(Icon::Microphone),
|
||||
Self::FileAttach => Some(Icon::Plus),
|
||||
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
|
||||
Self::FileExplorer => Some(Icon::FileCopy),
|
||||
Self::RichInput => Some(Icon::TextInput),
|
||||
Self::ShareSession => Some(Icon::Phone01),
|
||||
Self::Settings => Some(Icon::Settings),
|
||||
Self::FastForwardToggle => Some(Icon::FastForward),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_context_chip(&self) -> bool {
|
||||
matches!(self, Self::ContextChip(_))
|
||||
}
|
||||
|
||||
pub fn context_chip_kind(&self) -> Option<&ContextChipKind> {
|
||||
match self {
|
||||
Self::ContextChip(kind) => Some(kind),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default left-side items for the agent view footer.
|
||||
pub fn default_left() -> Vec<Self> {
|
||||
let mut items = vec![
|
||||
Self::ContextChip(ContextChipKind::Ssh),
|
||||
Self::ContextChip(ContextChipKind::WorkingDirectory),
|
||||
Self::ContextChip(ContextChipKind::ShellGitBranch),
|
||||
Self::ContextChip(ContextChipKind::GitDiffStats),
|
||||
];
|
||||
if FeatureFlag::GithubPrPromptChip.is_enabled() {
|
||||
items.push(Self::ContextChip(ContextChipKind::GithubPullRequest));
|
||||
}
|
||||
items.push(Self::NLDToggle);
|
||||
items
|
||||
}
|
||||
|
||||
/// Default right-side items for the agent view footer.
|
||||
pub fn default_right() -> Vec<Self> {
|
||||
let mut items = vec![
|
||||
Self::ContextChip(ContextChipKind::AgentPlanAndTodoList),
|
||||
Self::ContextWindowUsage,
|
||||
Self::ModelSelector,
|
||||
];
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
items.push(Self::VoiceInput);
|
||||
items.push(Self::FileAttach);
|
||||
items
|
||||
}
|
||||
|
||||
/// All items available for the agent view footer configurator.
|
||||
pub fn all_available() -> Vec<Self> {
|
||||
let mut items: Vec<Self> = agent_footer_available_chips()
|
||||
.into_iter()
|
||||
.map(Self::ContextChip)
|
||||
.collect();
|
||||
items.extend([
|
||||
Self::ModelSelector,
|
||||
Self::NLDToggle,
|
||||
Self::VoiceInput,
|
||||
Self::FileAttach,
|
||||
Self::ContextWindowUsage,
|
||||
]);
|
||||
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
|
||||
items.push(Self::FastForwardToggle);
|
||||
}
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Default left-side items for the CLI agent footer.
|
||||
pub fn cli_default_left() -> Vec<Self> {
|
||||
let mut items = vec![
|
||||
Self::FileAttach,
|
||||
Self::VoiceInput,
|
||||
Self::ContextChip(ContextChipKind::GitDiffStats),
|
||||
];
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
items.push(Self::FileExplorer);
|
||||
if FeatureFlag::CLIAgentRichInput.is_enabled() {
|
||||
items.push(Self::RichInput);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Default right-side items for the CLI agent footer.
|
||||
pub fn cli_default_right() -> Vec<Self> {
|
||||
vec![
|
||||
Self::ContextChip(ContextChipKind::WorkingDirectory),
|
||||
Self::ContextChip(ContextChipKind::ShellGitBranch),
|
||||
Self::Settings,
|
||||
]
|
||||
}
|
||||
|
||||
/// All items available for the CLI agent footer configurator.
|
||||
pub fn all_available_for_cli_input() -> Vec<Self> {
|
||||
let mut items: Vec<Self> = available_chips()
|
||||
.into_iter()
|
||||
.map(Self::ContextChip)
|
||||
.collect();
|
||||
items.extend([
|
||||
Self::FileExplorer,
|
||||
Self::RichInput,
|
||||
Self::FileAttach,
|
||||
Self::VoiceInput,
|
||||
Self::Settings,
|
||||
]);
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Returns the appropriate defaults and available items for a given editor mode.
|
||||
pub fn defaults_for_mode(mode: AgentToolbarEditorMode) -> (Vec<Self>, Vec<Self>, Vec<Self>) {
|
||||
match mode {
|
||||
AgentToolbarEditorMode::AgentView => (
|
||||
Self::default_left(),
|
||||
Self::default_right(),
|
||||
Self::all_available(),
|
||||
),
|
||||
AgentToolbarEditorMode::CLIAgent => (
|
||||
Self::cli_default_left(),
|
||||
Self::cli_default_right(),
|
||||
Self::all_available_for_cli_input(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ContextChipKind> for AgentToolbarItemKind {
|
||||
fn from(kind: ContextChipKind) -> Self {
|
||||
Self::ContextChip(kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{Container, Element, Empty, MouseStateHandle};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::platform::OperatingSystem;
|
||||
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
|
||||
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::{
|
||||
AIAgentExchangeId, AIAgentOutputStatus, FinishedAIAgentOutput, RenderableAIError,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
|
||||
BlocklistAIInputEvent, BlocklistAIInputModel,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
|
||||
use crate::ai::mcp::{
|
||||
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
|
||||
TemplatableMCPServerManager,
|
||||
};
|
||||
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::message_bar::attached_context::{
|
||||
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
|
||||
};
|
||||
use crate::terminal::input::message_bar::common::{
|
||||
disableable_message_item_color_overrides, render_standard_message_bar,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{
|
||||
ChipHorizontalAlignment, EmptyMessageProducer, Message, MessageItem, MessageProvider,
|
||||
};
|
||||
use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashCommandModel};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::workspace::tab_settings::{TabSettings, TabSettingsChangedEvent};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
const FIGMA_ICON_SIZE: f32 = 14.;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AgentMessageBarMouseStates {
|
||||
pub resume_conversation: MouseStateHandle,
|
||||
pub fork_from_last_known_good_state: MouseStateHandle,
|
||||
pub toggle_shortcuts: MouseStateHandle,
|
||||
pub toggle_slash_commands: MouseStateHandle,
|
||||
pub toggle_plan: MouseStateHandle,
|
||||
pub toggle_conversation_menu: MouseStateHandle,
|
||||
pub toggle_code_review: MouseStateHandle,
|
||||
pub clear_attached_context: MouseStateHandle,
|
||||
/// Mouse state handle for the "Get Figma MCP" contextual button.
|
||||
pub figma_install_button: MouseStateHandle,
|
||||
/// Mouse state handle for the "Enable Figma MCP" contextual button.
|
||||
pub figma_enable_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Renders contextual hint text at the bottom of the agent view status bar.
|
||||
pub struct AgentMessageBar {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
mouse_states: AgentMessageBarMouseStates,
|
||||
/// Whether the word "figma" has been detected in the current input buffer or attached images.
|
||||
/// Only meaningful when `FeatureFlag::FigmaDetection` is enabled.
|
||||
figma_detected: bool,
|
||||
}
|
||||
|
||||
impl Entity for AgentMessageBar {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl AgentMessageBar {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. }
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&ephemeral_message_model, |_, _, event, ctx| {
|
||||
if matches!(event, EphemeralMessageModelEvent::MessageChanged) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&input_model, |_, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
BlocklistAIInputEvent::InputTypeChanged { .. }
|
||||
| BlocklistAIInputEvent::LockChanged { .. }
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
|
||||
let InputBufferUpdateEvent {
|
||||
old_content: old,
|
||||
new_content: new,
|
||||
..
|
||||
} = event;
|
||||
// If the user inputs into the buffer, dismiss any explicit message if we have one.
|
||||
me.ephemeral_message_model
|
||||
.update(ctx, |m, ctx| m.try_dismiss_explicit_message(ctx));
|
||||
let empty_state_changed = old.is_empty() != new.is_empty();
|
||||
let in_shell_mode = !me.input_model.as_ref(ctx).is_ai_input_enabled();
|
||||
if empty_state_changed || in_shell_mode {
|
||||
ctx.notify();
|
||||
}
|
||||
me.update_figma_detected(ctx);
|
||||
});
|
||||
ctx.subscribe_to_model(&shortcut_view_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&input_suggestions_model, |me, _, event, ctx| match event {
|
||||
InputSuggestionsModeEvent::ModeChanged {
|
||||
buffer_to_restore: _,
|
||||
input_config_to_restore: _,
|
||||
} => {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_inline_menu_open() {
|
||||
// When opening an inline menu, dismiss any explicit message if we have one.
|
||||
me.ephemeral_message_model
|
||||
.update(ctx, |m, ctx| m.try_dismiss_explicit_message(ctx));
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&AIDocumentModel::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(event, AIDocumentModelEvent::DocumentVisibilityChanged(_)) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&context_model, |me, _, event, ctx| {
|
||||
if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event {
|
||||
me.update_figma_detected(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&TabSettings::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(event, TabSettingsChangedEvent::ShowCodeReviewButton { .. }) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
if FeatureFlag::FigmaDetection.is_enabled() {
|
||||
// When the state of the Figma MCP changes, re-render to update the Figma CTA button.
|
||||
ctx.subscribe_to_model(
|
||||
&TemplatableMCPServerManager::handle(ctx),
|
||||
|_, model, event, ctx| {
|
||||
if let TemplatableMCPServerManagerEvent::StateChanged { uuid, .. } = event {
|
||||
if let Some(figma_mcp_uuid) =
|
||||
model.as_ref(ctx).get_figma_installation_uuid()
|
||||
{
|
||||
if uuid == &figma_mcp_uuid {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
agent_view_controller,
|
||||
ephemeral_message_model,
|
||||
shortcut_view_model,
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
input_suggestions_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
terminal_model,
|
||||
mouse_states: AgentMessageBarMouseStates::default(),
|
||||
figma_detected: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentMessageBar {
|
||||
/// Sets `figma_detected` by checking both the current input text and attached images.
|
||||
/// `figma_detected` is `true` when either the text contains "figma" (case-insensitive)
|
||||
/// or any attached image was exported from Figma.
|
||||
fn update_figma_detected(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::FigmaDetection.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let text_has_figma = self
|
||||
.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.to_lowercase()
|
||||
.contains("figma");
|
||||
let image_has_figma = self
|
||||
.context_model
|
||||
.as_ref(ctx)
|
||||
.pending_images()
|
||||
.iter()
|
||||
.any(|image| image.is_figma);
|
||||
let detected = text_has_figma || image_has_figma;
|
||||
if self.figma_detected != detected {
|
||||
self.figma_detected = detected;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Figma MCP status if the contextual button area should be rendered
|
||||
/// (i.e. when `FeatureFlag::FigmaDetection` is enabled and "figma" is detected in the input).
|
||||
fn figma_button_status(&self, app: &AppContext) -> Option<FigmaMcpStatus> {
|
||||
if FeatureFlag::FigmaDetection.is_enabled() && self.figma_detected {
|
||||
Some(TemplatableMCPServerManager::as_ref(app).get_figma_mcp_status())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for AgentMessageBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"AgentMessageBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
// If an inline menu is open, a 'message line' is rendered by the inline menu itself,
|
||||
// so defer to that.
|
||||
let input_suggestions_model = self.input_suggestions_model.as_ref(app);
|
||||
if input_suggestions_model.is_inline_menu_open() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let shortcut_view_model = self.shortcut_view_model.as_ref(app);
|
||||
let input_buffer_model = self.input_buffer_model.as_ref(app);
|
||||
let input_model = self.input_model.as_ref(app);
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let context_model = self.context_model.as_ref(app);
|
||||
let slash_command_model = self.slash_command_model.as_ref(app);
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let Some(active_conversation) = agent_view_controller
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
.and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id))
|
||||
else {
|
||||
// This should never be hit, as the agent view requires there be an active conversation.
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
let ephemeral_message_model = self.ephemeral_message_model.as_ref(app);
|
||||
|
||||
let args = AgentMessageArgs {
|
||||
active_conversation,
|
||||
agent_view_controller,
|
||||
ephemeral_message_model,
|
||||
shortcut_view_model,
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
terminal_model: &terminal_model,
|
||||
appearance,
|
||||
app,
|
||||
mouse_states: &self.mouse_states,
|
||||
};
|
||||
|
||||
// Ephemeral messages take highest priority.
|
||||
let Some(mut message) = ephemeral_message_model
|
||||
.produce_message(args)
|
||||
.or_else(|| BootstrappingMessageProducer.produce_message(args))
|
||||
.or_else(|| ForkSlashCommandMessageProducer.produce_message(args))
|
||||
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
|
||||
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
|
||||
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
|
||||
.or_else(|| ZeroStateMessageProducer.produce_message(args))
|
||||
.or_else(|| EmptyMessageProducer.produce_message(args))
|
||||
else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
// Show credits banner when user has ambient credits remaining.
|
||||
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
|
||||
let right_element = if cfg!(target_family = "wasm") {
|
||||
None
|
||||
} else if let Some(credits) =
|
||||
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
|
||||
{
|
||||
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
|
||||
Some(render_ambient_credits_banner(credits, app))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Append a Figma MCP chip to the message if applicable.
|
||||
match self.figma_button_status(app) {
|
||||
Some(FigmaMcpStatus::NotInstalled) => {
|
||||
message.items.push(figma_chip(
|
||||
self.mouse_states.figma_install_button.clone(),
|
||||
"Get Figma MCP",
|
||||
Some(InputAction::FigmaAddButtonClicked),
|
||||
));
|
||||
}
|
||||
Some(FigmaMcpStatus::Installed) => {
|
||||
message.items.push(figma_chip(
|
||||
self.mouse_states.figma_enable_button.clone(),
|
||||
"Enable Figma MCP",
|
||||
Some(InputAction::FigmaEnableButtonClicked),
|
||||
));
|
||||
}
|
||||
Some(FigmaMcpStatus::Enabling) => {
|
||||
message.items.push(
|
||||
figma_chip(
|
||||
self.mouse_states.figma_enable_button.clone(),
|
||||
"Enabling...",
|
||||
None,
|
||||
)
|
||||
.with_is_disabled(true),
|
||||
);
|
||||
}
|
||||
Some(FigmaMcpStatus::Running) | None => {}
|
||||
}
|
||||
|
||||
let message_bar = render_standard_message_bar(message, right_element, app);
|
||||
if self.agent_view_controller.as_ref(app).is_inline() {
|
||||
Container::new(message_bar)
|
||||
.with_background(agent_view_bg_fill(app))
|
||||
.finish()
|
||||
} else {
|
||||
message_bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for agent message producers.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct AgentMessageArgs<'a> {
|
||||
pub active_conversation: &'a AIConversation,
|
||||
pub agent_view_controller: &'a AgentViewController,
|
||||
pub ephemeral_message_model: &'a EphemeralMessageModel,
|
||||
pub shortcut_view_model: &'a AgentShortcutViewModel,
|
||||
pub input_buffer_model: &'a InputBufferModel,
|
||||
pub input_model: &'a BlocklistAIInputModel,
|
||||
pub slash_command_model: &'a SlashCommandModel,
|
||||
pub context_model: &'a BlocklistAIContextModel,
|
||||
pub terminal_model: &'a TerminalModel,
|
||||
pub appearance: &'a Appearance,
|
||||
pub app: &'a AppContext,
|
||||
pub mouse_states: &'a AgentMessageBarMouseStates,
|
||||
}
|
||||
|
||||
impl AttachedContextArgs for AgentMessageArgs<'_> {
|
||||
fn terminal_model(&self) -> &TerminalModel {
|
||||
self.terminal_model
|
||||
}
|
||||
|
||||
fn input_buffer_model(&self) -> &InputBufferModel {
|
||||
self.input_buffer_model
|
||||
}
|
||||
|
||||
fn input_model(&self) -> &BlocklistAIInputModel {
|
||||
self.input_model
|
||||
}
|
||||
|
||||
fn agent_view_controller(&self) -> &AgentViewController {
|
||||
self.agent_view_controller
|
||||
}
|
||||
|
||||
fn context_model(&self) -> &BlocklistAIContextModel {
|
||||
self.context_model
|
||||
}
|
||||
|
||||
fn mouse_states(&self) -> &AgentMessageBarMouseStates {
|
||||
self.mouse_states
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a message while the shell is still bootstrapping.
|
||||
struct BootstrappingMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for BootstrappingMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
if args.terminal_model.block_list().is_bootstrapped()
|
||||
|| args.terminal_model.is_dummy_cloud_mode_session()
|
||||
|| args.terminal_model.is_shared_ambient_agent_session()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(Message::from_text("Starting shell..."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces the zero state message
|
||||
/// When a task is stopped, we also include "Cmd+Shift+R to resume conversation".
|
||||
/// When a plan exists for the active conversation, we also include "cmd-alt-p to view plan".
|
||||
struct ZeroStateMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
active_conversation,
|
||||
agent_view_controller,
|
||||
input_model,
|
||||
input_buffer_model,
|
||||
terminal_model,
|
||||
app,
|
||||
mouse_states,
|
||||
..
|
||||
} = args;
|
||||
|
||||
let is_locked_shell_input =
|
||||
!input_model.is_ai_input_enabled() && input_model.is_input_type_locked();
|
||||
if is_locked_shell_input {
|
||||
return None;
|
||||
}
|
||||
|
||||
let AgentViewState::Active {
|
||||
original_conversation_length,
|
||||
..
|
||||
} = agent_view_controller.agent_view_state()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
let show_resume = !active_conversation.is_entirely_passive()
|
||||
&& (active_conversation.status().is_cancelled()
|
||||
|| active_conversation.status().is_error());
|
||||
if show_resume {
|
||||
let resume_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-R").expect("keystroke should parse")
|
||||
} else {
|
||||
Keystroke::parse("ctrl-alt-r").expect("keystroke should parse")
|
||||
};
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(resume_keystroke),
|
||||
MessageItem::text("to resume conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ResumeConversation);
|
||||
},
|
||||
mouse_states.resume_conversation.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// Override to disabled text color if the buffer is not empty, because
|
||||
// these shortcuts require the buffer be empty to take effect.
|
||||
let is_buffer_empty = input_buffer_model.current_value().is_empty();
|
||||
let (
|
||||
color_override_for_shortcuts_and_commands,
|
||||
bg_color_override_for_shortcuts_and_commands,
|
||||
) = disableable_message_item_color_overrides(!is_buffer_empty, app);
|
||||
|
||||
items.push(
|
||||
MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "?".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
background_color: bg_color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "for help".into(),
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ToggleAgentViewShortcuts);
|
||||
},
|
||||
mouse_states.toggle_shortcuts.clone(),
|
||||
)
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
|
||||
items.push(
|
||||
MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "/".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
background_color: bg_color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "for commands".into(),
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ToggleSlashCommandsMenu);
|
||||
},
|
||||
mouse_states.toggle_slash_commands.clone(),
|
||||
)
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
|
||||
let is_cloud_agent = matches!(
|
||||
agent_view_controller.agent_view_state(),
|
||||
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
|
||||
);
|
||||
|
||||
let plan_count = AIDocumentModel::as_ref(app)
|
||||
.get_all_documents_for_conversation(active_conversation.id())
|
||||
.len();
|
||||
let has_plan = plan_count > 0;
|
||||
let has_conversation_been_updated_since_agent_view_entry =
|
||||
*original_conversation_length != active_conversation.exchange_count();
|
||||
|
||||
if !is_cloud_agent && !has_conversation_been_updated_since_agent_view_entry {
|
||||
if let Some(conversations_keystroke) =
|
||||
keybinding_name_to_keystroke(commands::CONVERSATIONS.name, app)
|
||||
{
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(conversations_keystroke),
|
||||
MessageItem::text("open conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ToggleConversationsMenu);
|
||||
},
|
||||
mouse_states.toggle_conversation_menu.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Code review only works locally.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
|
||||
let code_review_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-+").expect("keystroke should parse")
|
||||
};
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(code_review_keystroke),
|
||||
MessageItem::text("for code review"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ToggleRightPanel);
|
||||
},
|
||||
mouse_states.toggle_code_review.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if has_plan {
|
||||
let is_plan_for_this_conversation_open = agent_view_controller
|
||||
.pane_group_id()
|
||||
.is_some_and(|pane_group_id| {
|
||||
AIDocumentModel::as_ref(app).is_document_visible_by_conversation_in_pane_group(
|
||||
&active_conversation.id(),
|
||||
pane_group_id,
|
||||
)
|
||||
});
|
||||
|
||||
// If changing this text, ensure the logic is consistent with how TerminalAction::ToggleAIDocumentPane is handled.
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(
|
||||
Keystroke::parse("cmdorctrl-alt-p").expect("keystroke should parse"),
|
||||
),
|
||||
MessageItem::text(if is_plan_for_this_conversation_open {
|
||||
"to hide plan"
|
||||
} else if plan_count > 1 {
|
||||
"to view plans"
|
||||
} else {
|
||||
"to view plan"
|
||||
}),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ToggleAIDocumentPane);
|
||||
},
|
||||
mouse_states.toggle_plan.clone(),
|
||||
));
|
||||
}
|
||||
if fork_from_last_known_good_state_exchange_id(active_conversation, terminal_model)
|
||||
.is_some()
|
||||
{
|
||||
let fork_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-alt-y").expect("keystroke should parse")
|
||||
} else {
|
||||
Keystroke::parse("ctrl-alt-y").expect("keystroke should parse")
|
||||
};
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(fork_keystroke),
|
||||
MessageItem::text("to fork and continue"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
TerminalAction::ForkConversationFromLastKnownGoodState,
|
||||
);
|
||||
},
|
||||
mouse_states.fork_from_last_known_good_state.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fork_from_last_known_good_state_exchange_id(
|
||||
active_conversation: &AIConversation,
|
||||
terminal_model: &TerminalModel,
|
||||
) -> Option<AIAgentExchangeId> {
|
||||
if !should_fork_from_last_known_good_state(active_conversation, terminal_model) {
|
||||
return None;
|
||||
}
|
||||
|
||||
active_conversation
|
||||
.exchanges_reversed()
|
||||
.filter(|exchange| exchange.has_user_query())
|
||||
// Assumes the failed latest exchange is in the root task; exchanges_reversed only
|
||||
// iterates root-task exchanges.
|
||||
// Skip the failed latest user query; fork from the nearest prior successful one.
|
||||
.skip(1)
|
||||
.find(|exchange| exchange.output_status.is_finished_and_successful())
|
||||
.map(|exchange| exchange.id)
|
||||
}
|
||||
fn should_fork_from_last_known_good_state(
|
||||
active_conversation: &AIConversation,
|
||||
terminal_model: &TerminalModel,
|
||||
) -> bool {
|
||||
if terminal_model.is_conversation_transcript_viewer()
|
||||
|| terminal_model.shared_session_status().is_viewer()
|
||||
|| active_conversation.is_viewing_shared_session()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(latest_exchange) = active_conversation.latest_exchange() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let error = match &latest_exchange.output_status {
|
||||
AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Error { error, .. },
|
||||
} => error,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
match error {
|
||||
RenderableAIError::QuotaLimit
|
||||
| RenderableAIError::ServerOverloaded
|
||||
| RenderableAIError::ContextWindowExceeded(_)
|
||||
| RenderableAIError::InvalidApiKey { .. }
|
||||
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
|
||||
RenderableAIError::InternalWarpError => true,
|
||||
RenderableAIError::Other {
|
||||
will_attempt_resume,
|
||||
..
|
||||
} => !will_attempt_resume,
|
||||
}
|
||||
}
|
||||
|
||||
struct ForkSlashCommandMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let SlashCommandEntryState::SlashCommand(detected_command) =
|
||||
args.slash_command_model.state()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let command_name = detected_command.command.name;
|
||||
if command_name != commands::FORK.name
|
||||
&& command_name != commands::FORK_FROM.name
|
||||
&& command_name != commands::FORK_AND_COMPACT.name
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let modifier_keystroke = if cfg!(target_os = "macos") {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
|
||||
// Other fork-like commands open in the current pane with Enter and a new pane
|
||||
// with Cmd/Ctrl+Enter.
|
||||
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
|
||||
(" new pane", " new tab")
|
||||
} else {
|
||||
(" current pane", " new pane")
|
||||
};
|
||||
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(primary_label),
|
||||
MessageItem::keystroke(modifier_keystroke),
|
||||
MessageItem::text(secondary_label),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
struct HideShortcutsMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for HideShortcutsMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
if !args.shortcut_view_model.is_shortcut_view_open() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Message::new(vec![MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "?".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text("to hide help"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ToggleAgentViewShortcuts);
|
||||
},
|
||||
args.mouse_states.toggle_shortcuts.clone(),
|
||||
)]))
|
||||
}
|
||||
}
|
||||
|
||||
struct AutodetectedBashModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
slash_command_model,
|
||||
app,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled()
|
||||
|| input_model.is_input_type_locked()
|
||||
|| input_buffer_model.current_value().is_empty()
|
||||
|| slash_command_model.state().is_detected_command()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let message = match keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app) {
|
||||
Some(keystroke) => Message::new(vec![
|
||||
MessageItem::text("autodetected shell command, "),
|
||||
MessageItem::keystroke(keystroke),
|
||||
MessageItem::text(" to override"),
|
||||
])
|
||||
.with_text_color(appearance.theme().ansi_fg_blue()),
|
||||
None => Message::from_text("autodetected shell command"),
|
||||
};
|
||||
|
||||
Some(message)
|
||||
}
|
||||
}
|
||||
|
||||
struct ExitBashModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
|
||||
if input_buffer_model.current_value().is_empty() {
|
||||
(appearance.theme().ansi_fg_blue(), None, None)
|
||||
} else {
|
||||
(
|
||||
Fill::from(appearance.theme().ansi_fg_blue())
|
||||
.with_opacity(60)
|
||||
.into_solid(),
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_1(appearance.theme())),
|
||||
)
|
||||
};
|
||||
|
||||
Some(
|
||||
Message::new(vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "backspace".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: keystroke_color_override,
|
||||
background_color: keystroke_bg_color_override,
|
||||
},
|
||||
MessageItem::text("to exit shell mode"),
|
||||
])
|
||||
.with_text_color(text_color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `MessageItem::Chip` for a Figma MCP contextual action.
|
||||
/// When `action` is `Some`, the chip is interactive and dispatches that action on click.
|
||||
/// When `action` is `None`, the chip is returned without an action (caller should disable it).
|
||||
fn figma_chip(
|
||||
mouse_state: MouseStateHandle,
|
||||
label: &'static str,
|
||||
action: Option<InputAction>,
|
||||
) -> MessageItem {
|
||||
let items = vec![
|
||||
MessageItem::Image {
|
||||
source: AssetSource::Bundled {
|
||||
path: "bundled/svg/figma-colored.svg",
|
||||
},
|
||||
width: FIGMA_ICON_SIZE,
|
||||
height: FIGMA_ICON_SIZE,
|
||||
},
|
||||
MessageItem::text(label),
|
||||
];
|
||||
if let Some(action) = action {
|
||||
MessageItem::chip(
|
||||
items,
|
||||
move |ctx| ctx.dispatch_typed_action(action.clone()),
|
||||
mouse_state,
|
||||
)
|
||||
.with_horizontal_alignment(ChipHorizontalAlignment::Right)
|
||||
} else {
|
||||
MessageItem::Chip {
|
||||
items,
|
||||
action: Arc::new(|_| {}),
|
||||
mouse_state,
|
||||
disabled: true,
|
||||
horizontal_alignment: ChipHorizontalAlignment::Right,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use settings::Setting;
|
||||
use warp_core::ui::{appearance::Appearance, Icon};
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Style, Weight::Bold},
|
||||
platform::Cursor,
|
||||
prelude::{Border, CornerRadius, Radius},
|
||||
text_layout::ClipConfig,
|
||||
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
active_agent_views_model::ActiveAgentViewsModel,
|
||||
agent::conversation::{AIConversationId, ConversationStatus},
|
||||
blocklist::BlocklistAIHistoryEvent,
|
||||
},
|
||||
terminal::BlockListSettings,
|
||||
ui_components::blended_colors,
|
||||
view_components::DismissibleToast,
|
||||
workspace::{ToastStack, WorkspaceAction},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
|
||||
use super::{AgentViewController, AgentViewEntryOrigin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
block: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct AgentViewEntryBlockParams {
|
||||
pub conversation_id: AIConversationId,
|
||||
pub is_new: bool,
|
||||
pub is_restored: bool,
|
||||
pub origin: AgentViewEntryOrigin,
|
||||
pub agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
/// Rich content block rendered in the terminal mode blocklist to represent an Agent View entry for
|
||||
/// a given conversation.
|
||||
pub struct AgentViewEntryBlock {
|
||||
conversation_id: AIConversationId,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
is_new: bool,
|
||||
is_restored: bool,
|
||||
origin: AgentViewEntryOrigin,
|
||||
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
|
||||
cached_title: Option<String>,
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
impl AgentViewEntryBlock {
|
||||
pub fn new(params: AgentViewEntryBlockParams, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let AgentViewEntryBlockParams {
|
||||
conversation_id,
|
||||
is_new,
|
||||
is_restored,
|
||||
origin,
|
||||
agent_view_controller,
|
||||
} = params;
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id, ..
|
||||
} if *conversation_id == me.conversation_id => {
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id,
|
||||
conversation_title,
|
||||
..
|
||||
} if *conversation_id == me.conversation_id => {
|
||||
me.cached_title = conversation_title.clone();
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
Self {
|
||||
conversation_id,
|
||||
agent_view_controller,
|
||||
is_new,
|
||||
is_restored,
|
||||
origin,
|
||||
cached_title: Default::default(),
|
||||
state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_block_container(
|
||||
origin: AgentViewEntryOrigin,
|
||||
content: Box<dyn Element>,
|
||||
background: ColorU,
|
||||
appearance: &Appearance,
|
||||
are_block_dividers_enabled: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let border = if are_block_dividers_enabled {
|
||||
Border::top(1.).with_border_fill(appearance.theme().outline())
|
||||
} else {
|
||||
Border::new(1.)
|
||||
.with_sides(true, false, true, false)
|
||||
.with_border_fill(appearance.theme().outline())
|
||||
};
|
||||
|
||||
let mut container = Container::new(content).with_background(background);
|
||||
|
||||
if matches!(origin, AgentViewEntryOrigin::LongRunningCommand) {
|
||||
container = container
|
||||
.with_uniform_padding(12.)
|
||||
.with_horizontal_margin(16.)
|
||||
.with_margin_bottom(16.)
|
||||
.with_margin_top(8.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
|
||||
} else {
|
||||
container = container
|
||||
.with_horizontal_padding(20.)
|
||||
.with_vertical_padding(18.)
|
||||
.with_border(border);
|
||||
}
|
||||
|
||||
container.finish()
|
||||
}
|
||||
|
||||
fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Text::new(
|
||||
text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 2.,
|
||||
)
|
||||
.with_color(blended_colors::text_disabled(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.with_style(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_deleted_state(
|
||||
origin: AgentViewEntryOrigin,
|
||||
cached_title: Option<String>,
|
||||
appearance: &Appearance,
|
||||
are_block_dividers_enabled: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let disabled_color =
|
||||
blended_colors::text_disabled(appearance.theme(), appearance.theme().background());
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Text::new(
|
||||
cached_title.unwrap_or_else(|| "Deleted conversation".to_string()),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(disabled_color)
|
||||
.with_style(Properties {
|
||||
weight: Bold,
|
||||
..Default::default()
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_child(render_subtext("Deleted".to_string(), appearance))
|
||||
.finish();
|
||||
|
||||
render_block_container(
|
||||
origin,
|
||||
row,
|
||||
blended_colors::fg_overlay_1(appearance.theme()).into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
}
|
||||
|
||||
impl View for AgentViewEntryBlock {
|
||||
fn ui_name() -> &'static str {
|
||||
"EnterAgentBlock"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
if self.agent_view_controller.as_ref(app).is_fullscreen() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let are_block_dividers_enabled =
|
||||
*BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let Some(conversation) = history_model.conversation(&self.conversation_id) else {
|
||||
// If the agent_view_block's conversation no longer exists,
|
||||
// we assume that it has been deleted.
|
||||
return render_deleted_state(
|
||||
self.origin,
|
||||
self.cached_title.clone(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
);
|
||||
};
|
||||
|
||||
if conversation.is_entirely_passive() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
|
||||
color.a = opacity;
|
||||
color
|
||||
}
|
||||
|
||||
let status_icon = conversation.status().render_icon(appearance);
|
||||
let status_icon_bg = match conversation.status() {
|
||||
ConversationStatus::InProgress => {
|
||||
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
|
||||
}
|
||||
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
|
||||
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
|
||||
ConversationStatus::Cancelled => {
|
||||
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
|
||||
}
|
||||
ConversationStatus::Blocked { .. } => {
|
||||
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
|
||||
}
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(status_icon.finish())
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_background_color(status_icon_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Text::new(
|
||||
conversation
|
||||
.title()
|
||||
.unwrap_or("Untitled conversation".to_string()),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.with_style(Properties {
|
||||
weight: Bold,
|
||||
..Default::default()
|
||||
})
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(self.conversation_id);
|
||||
let is_open_elsewhere = is_active && !is_active_in_this_pane;
|
||||
|
||||
let subtext = if is_open_elsewhere {
|
||||
Some("Open in different pane")
|
||||
} else if self.is_restored {
|
||||
Some("Restored")
|
||||
} else if !self.is_new
|
||||
&& !matches!(
|
||||
self.origin,
|
||||
AgentViewEntryOrigin::LongRunningCommand
|
||||
| AgentViewEntryOrigin::AgentRequestedNewConversation
|
||||
)
|
||||
{
|
||||
Some("Continued")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(subtext) = subtext {
|
||||
row.add_child(render_subtext(subtext.to_string(), appearance));
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::ChevronRight
|
||||
.to_warpui_icon(
|
||||
blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let conversation_id = self.conversation_id;
|
||||
let origin = self.origin;
|
||||
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
|
||||
let background = if hoverable_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
render_block_container(
|
||||
origin,
|
||||
row.finish(),
|
||||
background.into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentViewEntryBlockEvent {
|
||||
EnterAgentView { conversation_id: AIConversationId },
|
||||
}
|
||||
|
||||
impl Entity for AgentViewEntryBlock {
|
||||
type Event = AgentViewEntryBlockEvent;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EnterAgentBlockAction {
|
||||
EnterAgentMode { conversation_id: AIConversationId },
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentViewEntryBlock {
|
||||
type Action = EnterAgentBlockAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
EnterAgentBlockAction::EnterAgentMode { conversation_id } => {
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(*conversation_id, ctx);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(ctx)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(*conversation_id);
|
||||
|
||||
if is_active && !is_active_in_this_pane {
|
||||
let Some(target_terminal_view_id) = ActiveAgentViewsModel::as_ref(ctx)
|
||||
.terminal_view_id_for_conversation(*conversation_id, ctx)
|
||||
else {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::error(
|
||||
"Couldn't navigate to conversation.".to_string(),
|
||||
),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action_deferred(
|
||||
WorkspaceAction::FocusTerminalViewInWorkspace {
|
||||
terminal_view_id: target_terminal_view_id,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ctx.emit(AgentViewEntryBlockEvent::EnterAgentView {
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warpui::elements::{Element, Empty, Flex, MouseStateHandle, ParentElement};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::prelude::Container;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_navigation_card_with_icon;
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::buttons::close_button;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChildAgentStatusCardAction {
|
||||
Dismiss(AIConversationId),
|
||||
}
|
||||
|
||||
/// Renders a list of child agent statuses above the agent message bar.
|
||||
///
|
||||
/// Each row shows a status icon, agent name, and conversation title.
|
||||
/// Clicking a row reveals the child agent's hidden pane.
|
||||
/// Cards can be dismissed via an X button and automatically reappear
|
||||
/// when the child agent starts or restarts (transitions to InProgress).
|
||||
pub struct ChildAgentStatusCard {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
mouse_states: HashMap<AIConversationId, MouseStateHandle>,
|
||||
dismiss_mouse_states: HashMap<AIConversationId, MouseStateHandle>,
|
||||
dismissed: HashSet<AIConversationId>,
|
||||
previous_statuses: HashMap<AIConversationId, ConversationStatus>,
|
||||
}
|
||||
|
||||
impl Entity for ChildAgentStatusCard {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl ChildAgentStatusCard {
|
||||
pub fn new(
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Subscribe without terminal_view_id filtering so we receive status
|
||||
// updates for child conversations (which have a different terminal_view_id).
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |this, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
this.on_conversation_status_updated(*conversation_id, ctx);
|
||||
this.ensure_mouse_states(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIHistoryEvent::AppendedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. } => {
|
||||
this.ensure_mouse_states(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIHistoryEvent::RemoveConversation {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
this.remove_state_for_conversation(*conversation_id);
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |this, _, event, ctx| {
|
||||
// Reset all per-child state when entering a conversation so stale
|
||||
// entries from a previous conversation's children don't accumulate.
|
||||
if matches!(event, AgentViewControllerEvent::EnteredAgentView { .. }) {
|
||||
this.dismissed.clear();
|
||||
this.previous_statuses.clear();
|
||||
this.mouse_states.clear();
|
||||
this.dismiss_mouse_states.clear();
|
||||
}
|
||||
this.ensure_mouse_states(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
agent_view_controller,
|
||||
mouse_states: HashMap::new(),
|
||||
dismiss_mouse_states: HashMap::new(),
|
||||
dismissed: HashSet::new(),
|
||||
previous_statuses: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_mouse_states(&mut self, ctx: &AppContext) {
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(ctx);
|
||||
let Some(active_conversation_id) = agent_view_controller
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
for child in history_model.child_conversations_of(active_conversation_id) {
|
||||
let child_id = child.id();
|
||||
self.mouse_states.entry(child_id).or_default();
|
||||
self.dismiss_mouse_states.entry(child_id).or_default();
|
||||
self.previous_statuses
|
||||
.entry(child_id)
|
||||
.or_insert_with(|| child.status().clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_state_for_conversation(&mut self, conversation_id: AIConversationId) {
|
||||
self.dismissed.remove(&conversation_id);
|
||||
self.previous_statuses.remove(&conversation_id);
|
||||
self.mouse_states.remove(&conversation_id);
|
||||
self.dismiss_mouse_states.remove(&conversation_id);
|
||||
}
|
||||
|
||||
/// Checks whether a child conversation transitioned to `InProgress` from a
|
||||
/// non-`InProgress` state, mirroring the Started/Restarted lifecycle event
|
||||
/// logic in `OrchestrationEventService::on_conversation_status_updated`.
|
||||
/// If so, restores any dismissed card for that conversation.
|
||||
fn on_conversation_status_updated(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) {
|
||||
let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !conversation.is_child_agent_conversation() {
|
||||
return;
|
||||
}
|
||||
let current_status = conversation.status().clone();
|
||||
if should_restore_dismissed_card(
|
||||
¤t_status,
|
||||
self.previous_statuses.get(&conversation_id),
|
||||
) {
|
||||
self.dismissed.remove(&conversation_id);
|
||||
}
|
||||
self.previous_statuses
|
||||
.insert(conversation_id, current_status);
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ChildAgentStatusCard {
|
||||
type Action = ChildAgentStatusCardAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ChildAgentStatusCardAction::Dismiss(conversation_id) => {
|
||||
self.dismissed.insert(*conversation_id);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for ChildAgentStatusCard {
|
||||
fn ui_name() -> &'static str {
|
||||
"ChildAgentStatusCard"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let Some(active_conversation_id) = agent_view_controller
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
else {
|
||||
return Empty::new().finish();
|
||||
};
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let mut children = history_model.child_conversations_of(active_conversation_id);
|
||||
if children.is_empty() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
// Sort by creation time so rows have a stable visual order.
|
||||
children.sort_by_key(|c| c.first_exchange().map(|e| e.start_time));
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let mut column = Flex::column();
|
||||
|
||||
for child in &children {
|
||||
let conversation_id = child.id();
|
||||
if self.dismissed.contains(&conversation_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let agent_name = child.agent_name().unwrap_or("Agent").to_string();
|
||||
let title = child.title().unwrap_or_else(|| "Untitled".to_string());
|
||||
let status_icon = child.status().status_icon_and_color(appearance.theme());
|
||||
|
||||
let Some(mouse_state) = self.mouse_states.get(&conversation_id).cloned() else {
|
||||
log::error!(
|
||||
"Missing mouse state handle for child agent card {:?}",
|
||||
conversation_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(dismiss_mouse_state) =
|
||||
self.dismiss_mouse_states.get(&conversation_id).cloned()
|
||||
else {
|
||||
log::error!(
|
||||
"Missing dismiss mouse state handle for child agent card {:?}",
|
||||
conversation_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let dismiss_button = close_button(appearance, dismiss_mouse_state)
|
||||
.build()
|
||||
.on_click(move |ctx: &mut warpui::EventContext<'_>, _, _| {
|
||||
ctx.dispatch_typed_action(ChildAgentStatusCardAction::Dismiss(conversation_id));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let card = conversation_navigation_card_with_icon(
|
||||
Some(status_icon),
|
||||
agent_name,
|
||||
Some(title),
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
|
||||
},
|
||||
mouse_state,
|
||||
true,
|
||||
Some(dismiss_button),
|
||||
app,
|
||||
);
|
||||
column.add_child(Container::new(card).with_margin_top(4.).finish());
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a dismissed card should be restored: the conversation
|
||||
/// transitioned to `InProgress` from a non-`InProgress` state, matching the
|
||||
/// Started/Restarted lifecycle event semantics.
|
||||
fn should_restore_dismissed_card(
|
||||
current_status: &ConversationStatus,
|
||||
previous_status: Option<&ConversationStatus>,
|
||||
) -> bool {
|
||||
let was_in_progress = previous_status.is_some_and(|s| s.is_in_progress());
|
||||
current_status.is_in_progress() && !was_in_progress
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageProvider};
|
||||
|
||||
use super::agent_message_bar::AgentMessageArgs;
|
||||
|
||||
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
|
||||
|
||||
pub struct EphemeralMessage {
|
||||
/// Optional id that may be used to identify the message.
|
||||
id: Option<Cow<'static, str>>,
|
||||
|
||||
/// The message to be displayed.
|
||||
message: Message,
|
||||
|
||||
/// The strategy to be used to determine when to stop showing the message.
|
||||
dismissal: DismissalStrategy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DismissalStrategy {
|
||||
/// Persists until explicitly dismissed by the input.
|
||||
UntilExplicitlyDismissed,
|
||||
|
||||
/// Auto-dismiss after a duration elapses.
|
||||
Timer(Duration),
|
||||
}
|
||||
|
||||
impl EphemeralMessage {
|
||||
pub fn new(message: Message, dismissal: DismissalStrategy) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
message,
|
||||
dismissal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_duration(mut self, duration: Duration) -> Self {
|
||||
self.dismissal = DismissalStrategy::Timer(duration);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.id.as_ref().map(|id| id.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages messages that are dismissed either explicitly by the input or after a fixed duration.
|
||||
pub struct EphemeralMessageModel {
|
||||
current_message: Option<EphemeralMessage>,
|
||||
clear_timer_handle: Option<SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum EphemeralMessageModelEvent {
|
||||
MessageChanged,
|
||||
}
|
||||
|
||||
impl EphemeralMessageModel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_message: None,
|
||||
clear_timer_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_message(&self) -> Option<&EphemeralMessage> {
|
||||
self.current_message.as_ref()
|
||||
}
|
||||
|
||||
/// Shows a message with the given dismissal strategy.
|
||||
pub fn show_ephemeral_message(
|
||||
&mut self,
|
||||
message: EphemeralMessage,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(handle) = self.clear_timer_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let dismissal = message.dismissal;
|
||||
self.current_message = Some(message);
|
||||
|
||||
// If we are dismissing via timer, start the timer.
|
||||
if let DismissalStrategy::Timer(duration) = dismissal {
|
||||
let abort_handle = ctx.spawn_abortable(
|
||||
async move { warpui::r#async::Timer::after(duration).await },
|
||||
|me, _, ctx| {
|
||||
me.current_message = None;
|
||||
me.clear_timer_handle = None;
|
||||
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
|
||||
},
|
||||
|_, _| (),
|
||||
);
|
||||
self.clear_timer_handle = Some(abort_handle);
|
||||
}
|
||||
|
||||
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
|
||||
}
|
||||
|
||||
pub fn show_info_ephemeral_message(
|
||||
&mut self,
|
||||
message: impl Into<Cow<'static, str>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.show_ephemeral_message(
|
||||
EphemeralMessage::new(
|
||||
Message::from_text(message),
|
||||
DismissalStrategy::Timer(DEFAULT_MESSAGE_DURATION),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dismisses the current message if it is not timer-based.
|
||||
pub fn try_dismiss_explicit_message(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let should_dismiss = self
|
||||
.current_message
|
||||
.as_ref()
|
||||
.is_some_and(|m| matches!(m.dismissal, DismissalStrategy::UntilExplicitlyDismissed));
|
||||
if should_dismiss {
|
||||
self.current_message = None;
|
||||
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditionally clears the current message and cancels any active timer.
|
||||
pub fn clear_message(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(handle) = self.clear_timer_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
if self.current_message.take().is_some() {
|
||||
ctx.emit(EphemeralMessageModelEvent::MessageChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for EphemeralMessageModel {
|
||||
type Event = EphemeralMessageModelEvent;
|
||||
}
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for EphemeralMessageModel {
|
||||
fn produce_message(&self, _args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
self.current_message()
|
||||
.map(|ephemeral_message| ephemeral_message.message.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
|
||||
use parking_lot::FairMutex;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
elements::{CornerRadius, Radius},
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::icons,
|
||||
blocklist::{
|
||||
block::cli_controller::LongRunningCommandControlState,
|
||||
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
|
||||
},
|
||||
},
|
||||
terminal::{model::session::Sessions, TerminalModel},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
};
|
||||
|
||||
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
|
||||
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
|
||||
const AGENT_WAITING_FOR_COMMAND_TO_EXIT_MESSAGE: &str = "Agent is waiting for command to exit";
|
||||
const AGENT_BLOCKED_MESSAGE: &str = "Agent needs your permission to continue";
|
||||
const AGENT_IN_CONTROL_MESSAGE: &str = "Agent is in control";
|
||||
const USER_IN_CONTROL_MESSAGE: &str = "User is in control";
|
||||
|
||||
/// A header rendered as rich content above the active block when Agent View is in inline mode.
|
||||
pub struct InlineAgentViewHeader {
|
||||
terminal_view_id: EntityId,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
sessions_model: ModelHandle<Sessions>,
|
||||
action_model: ModelHandle<BlocklistAIActionModel>,
|
||||
}
|
||||
|
||||
impl InlineAgentViewHeader {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
sessions_model: ModelHandle<Sessions>,
|
||||
action_model: ModelHandle<BlocklistAIActionModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.is_some_and(|id| id != me.terminal_view_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
|
||||
| BlocklistAIHistoryEvent::AppendedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. } => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&action_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
terminal_view_id,
|
||||
terminal_model,
|
||||
sessions_model,
|
||||
action_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineAgentViewHeader {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for InlineAgentViewHeader {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineAgentViewHeader"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
let active_conversation = history_model.active_conversation(self.terminal_view_id);
|
||||
let conversation_status = active_conversation.map(|conv| conv.status().clone());
|
||||
// Use active conversation's latest_exchange to include subtask exchanges (e.g., CLI subagent)
|
||||
let is_streaming = active_conversation
|
||||
.and_then(|conv| conv.latest_exchange())
|
||||
.map(|exchange| exchange.output_status.is_streaming())
|
||||
.unwrap_or(false);
|
||||
|
||||
let (
|
||||
is_agent_tagged_in,
|
||||
is_agent_in_control,
|
||||
is_user_in_control,
|
||||
is_action_blocked,
|
||||
top_level_command,
|
||||
) = {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
let sessions = self.sessions_model.as_ref(app);
|
||||
(
|
||||
active_block.is_agent_tagged_in(),
|
||||
active_block
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::is_agent_in_control),
|
||||
active_block
|
||||
.long_running_control_state()
|
||||
.is_some_and(LongRunningCommandControlState::is_user_in_control),
|
||||
active_block.is_agent_blocked(),
|
||||
active_block.top_level_command(sessions),
|
||||
)
|
||||
};
|
||||
if is_agent_tagged_in {
|
||||
let header_background = appearance.theme().surface_2();
|
||||
let icon = Icon::Oz.to_warpui_icon(
|
||||
blended_colors::text_main(appearance.theme(), header_background).into(),
|
||||
);
|
||||
let message = if let Some(command) = top_level_command.as_deref() {
|
||||
format!("{AGENT_PROMPT_TO_INTERACT_MESSAGE} `{command}`")
|
||||
} else {
|
||||
format!("{AGENT_PROMPT_TO_INTERACT_MESSAGE} the running command")
|
||||
};
|
||||
return HeaderConfig::new(message, app)
|
||||
.with_icon(icon)
|
||||
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.with_markdown()
|
||||
.render(app);
|
||||
}
|
||||
|
||||
let action_model = self.action_model.as_ref(app);
|
||||
let action = action_model.get_async_running_action(app);
|
||||
let is_waiting_for_command_to_exit = action.as_ref().is_some_and(|action| {
|
||||
matches!(
|
||||
action.action,
|
||||
AIAgentActionType::ReadShellCommandOutput {
|
||||
delay: Some(ShellCommandDelay::OnCompletion),
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let is_waiting_on_instructions =
|
||||
action.is_none() && !is_streaming && is_agent_in_control && !is_action_blocked;
|
||||
let message = if is_user_in_control {
|
||||
USER_IN_CONTROL_MESSAGE.to_owned()
|
||||
} else if is_action_blocked {
|
||||
AGENT_BLOCKED_MESSAGE.to_owned()
|
||||
} else if is_waiting_for_command_to_exit {
|
||||
AGENT_WAITING_FOR_COMMAND_TO_EXIT_MESSAGE.to_owned()
|
||||
} else if is_waiting_on_instructions {
|
||||
AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE.to_owned()
|
||||
} else {
|
||||
AGENT_IN_CONTROL_MESSAGE.to_owned()
|
||||
};
|
||||
|
||||
let icon = if is_user_in_control || is_waiting_on_instructions {
|
||||
icons::gray_stop_icon(appearance)
|
||||
} else if let Some(status) = &conversation_status {
|
||||
status.render_icon(appearance)
|
||||
} else {
|
||||
icons::in_progress_icon(appearance)
|
||||
};
|
||||
|
||||
HeaderConfig::new(message, app)
|
||||
.with_icon(icon)
|
||||
.with_corner_radius_override(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.render(app)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
pub(crate) mod agent_input_footer;
|
||||
mod agent_message_bar;
|
||||
mod agent_view_block;
|
||||
pub mod child_agent_status_card;
|
||||
mod controller;
|
||||
mod ephemeral_message_model;
|
||||
mod inline_agent_view_header;
|
||||
// TODO: Move orchestration_conversation_links module import elsewhere.
|
||||
pub(crate) mod orchestration_conversation_links;
|
||||
pub mod shortcuts;
|
||||
mod zero_state_block;
|
||||
|
||||
pub use agent_input_footer::*;
|
||||
pub use agent_message_bar::*;
|
||||
pub use agent_view_block::*;
|
||||
pub use controller::*;
|
||||
pub use ephemeral_message_model::*;
|
||||
pub use inline_agent_view_header::*;
|
||||
use warpui::fonts::Properties;
|
||||
pub use zero_state_block::*;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::{appearance::Appearance, color::blend::Blend};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::view_components::action_button::ActionButtonTheme;
|
||||
|
||||
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
Keystroke {
|
||||
cmd: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
|
||||
LazyLock::new(|| {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
Keystroke {
|
||||
cmd: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.theme().surface_overlay_1()
|
||||
}
|
||||
|
||||
pub fn agent_view_bg_color(app: &AppContext) -> ColorU {
|
||||
agent_view_bg_fill(app)
|
||||
.blend(&Appearance::as_ref(app).theme().background())
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
pub struct AgentViewHeaderTheme;
|
||||
|
||||
impl ActionButtonTheme for AgentViewHeaderTheme {
|
||||
fn background(&self, _: bool, _: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
hovered: bool,
|
||||
background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
if hovered {
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(background.unwrap_or(appearance.theme().background()))
|
||||
.into_solid()
|
||||
} else {
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(background.unwrap_or(appearance.theme().background()))
|
||||
.into_solid()
|
||||
}
|
||||
}
|
||||
|
||||
fn font_properties(&self) -> Option<Properties> {
|
||||
Some(Properties::default())
|
||||
}
|
||||
|
||||
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
|
||||
Some(appearance.theme().surface_overlay_2().into_solid())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AgentViewHeaderDisabledTheme;
|
||||
|
||||
impl ActionButtonTheme for AgentViewHeaderDisabledTheme {
|
||||
fn background(&self, _: bool, _: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(&self, _: bool, background: Option<Fill>, appearance: &Appearance) -> ColorU {
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(background.unwrap_or(appearance.theme().background()))
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
fn keyboard_shortcut_background(&self, _: &Appearance) -> Option<ColorU> {
|
||||
None
|
||||
}
|
||||
|
||||
fn font_properties(&self) -> Option<Properties> {
|
||||
Some(Properties::default())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
|
||||
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight::Bold},
|
||||
platform::Cursor,
|
||||
text_layout::ClipConfig,
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
api::ServerConversationToken,
|
||||
conversation::{AIConversation, AIConversationId},
|
||||
},
|
||||
agent_conversations_model::AgentConversationsModel,
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
workspace::{RestoreConversationLayout, WorkspaceAction},
|
||||
};
|
||||
|
||||
pub(crate) fn conversation_id_for_agent_id(
|
||||
agent_id: &str,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
history_model
|
||||
.conversation_id_for_agent_id(agent_id)
|
||||
.or_else(|| {
|
||||
history_model.find_conversation_id_by_server_token(&ServerConversationToken::new(
|
||||
agent_id.to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_id(
|
||||
active_conversation: &AIConversation,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
active_conversation.parent_conversation_id().or_else(|| {
|
||||
active_conversation
|
||||
.parent_agent_id()
|
||||
.and_then(|id| conversation_id_for_agent_id(id, app))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_navigation_action(
|
||||
conversation_id: AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> WorkspaceAction {
|
||||
AgentConversationsModel::as_ref(app)
|
||||
.get_conversation(&conversation_id)
|
||||
.and_then(|conversation| {
|
||||
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
|
||||
})
|
||||
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
|
||||
pane_view_locator: None,
|
||||
window_id: None,
|
||||
conversation_id,
|
||||
terminal_view_id: None,
|
||||
restore_layout: Some(RestoreConversationLayout::SplitPane),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_navigation_card(
|
||||
active_conversation: &AIConversation,
|
||||
mouse_state: MouseStateHandle,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let parent_conversation_id = parent_conversation_id(active_conversation, app)?;
|
||||
let parent_title = BlocklistAIHistoryModel::as_ref(app)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.title())
|
||||
.unwrap_or_else(|| "Parent conversation".to_string());
|
||||
let action = conversation_navigation_action(parent_conversation_id, app);
|
||||
Some(conversation_navigation_card(
|
||||
parent_title,
|
||||
Some("Back to parent conversation".to_string()),
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(action.clone());
|
||||
},
|
||||
mouse_state,
|
||||
false,
|
||||
app,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_navigation_card(
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
on_click: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
expands_to_max_width: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
conversation_navigation_card_with_icon(
|
||||
None,
|
||||
title,
|
||||
subtitle,
|
||||
on_click,
|
||||
mouse_state,
|
||||
expands_to_max_width,
|
||||
None,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders a clickable card with an optional leading icon, title/subtitle,
|
||||
/// a trailing chevron, and an optional extra trailing element (e.g. a dismiss
|
||||
/// button). When `extra_trailing` is provided, the card's Hoverable uses
|
||||
/// `defer_events_to_children` so the trailing element can handle its own
|
||||
/// click without also triggering the card's `on_click`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn conversation_navigation_card_with_icon(
|
||||
icon: Option<(Icon, ColorU)>,
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
on_click: impl FnMut(&mut EventContext, &AppContext, Vector2F) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
expands_to_max_width: bool,
|
||||
extra_trailing: Option<Box<dyn Element>>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let has_extra_trailing = extra_trailing.is_some();
|
||||
|
||||
let mut hoverable = Hoverable::new(mouse_state, move |hover_state| {
|
||||
let background = if hover_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(theme)
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(theme)
|
||||
};
|
||||
|
||||
let mut text_column = Flex::column().with_child(
|
||||
Text::new(
|
||||
title.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.with_style(Properties {
|
||||
weight: Bold,
|
||||
..Default::default()
|
||||
})
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
if let Some(subtitle) = subtitle.as_ref() {
|
||||
text_column.add_child(
|
||||
Text::new(
|
||||
subtitle.clone(),
|
||||
appearance.ui_font_family(),
|
||||
(appearance.monospace_font_size() - 2.).max(10.),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
let text_column = text_column.finish();
|
||||
|
||||
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if let Some((icon, color)) = icon {
|
||||
row.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(color)).finish())
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(6.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
if expands_to_max_width {
|
||||
row = row
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Shrinkable::new(1., text_column).finish());
|
||||
} else {
|
||||
row = row.with_child(text_column);
|
||||
}
|
||||
row.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::ChevronRight
|
||||
.to_warpui_icon(blended_colors::text_sub(theme, theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
);
|
||||
if let Some(trailing) = extra_trailing {
|
||||
// Spacer pushes the trailing element to the far right edge.
|
||||
row.add_child(Expanded::new(1., Empty::new().finish()).finish());
|
||||
row.add_child(trailing);
|
||||
}
|
||||
let row = row.finish();
|
||||
|
||||
Container::new(row)
|
||||
.with_background_color(background.into())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_horizontal_padding(10.)
|
||||
.with_vertical_padding(8.)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(on_click);
|
||||
|
||||
// When an extra trailing element is present (e.g. dismiss button), defer
|
||||
// click events to children so the trailing element's click handler takes
|
||||
// precedence over the card's navigation handler.
|
||||
if has_extra_trailing {
|
||||
hoverable = hoverable.with_defer_events_to_children();
|
||||
}
|
||||
|
||||
hoverable.finish()
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use warpui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
|
||||
keymap::Keystroke,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE;
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
cmd_or_ctrl_shift,
|
||||
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
|
||||
ui_components::blended_colors,
|
||||
util::bindings::keybinding_name_to_keystroke,
|
||||
workspace::view::{
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct AgentShortcutsViewContext {
|
||||
pub is_cloud_agent: bool,
|
||||
/// True once the user has submitted the first prompt.
|
||||
pub has_submitted_first_prompt: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ShortcutProps {
|
||||
pub keystroke: Keystroke,
|
||||
pub text: Cow<'static, str>,
|
||||
pub text_color: Option<ColorU>,
|
||||
}
|
||||
|
||||
pub fn render_shortcut(props: ShortcutProps, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = styles::font_size(appearance);
|
||||
let font_color = props.text_color.unwrap_or_else(|| {
|
||||
theme
|
||||
.sub_text_color(blended_colors::neutral_1(theme).into())
|
||||
.into()
|
||||
});
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(render_keystroke(&props.keystroke, app))
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Text::new(props.text, appearance.ui_font_family(), font_size)
|
||||
.with_color(font_color)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_keystroke(keystroke: &Keystroke, app: &AppContext) -> Box<dyn Element> {
|
||||
render_keystroke_with_color_overrides(keystroke, None, None, app)
|
||||
}
|
||||
|
||||
pub fn render_keystroke_with_color_overrides(
|
||||
keystroke: &Keystroke,
|
||||
color: Option<ColorU>,
|
||||
background_color: Option<ColorU>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = styles::font_size(appearance);
|
||||
appearance
|
||||
.ui_builder()
|
||||
.keyboard_shortcut(keystroke)
|
||||
.lowercase_modifier()
|
||||
.with_space_between_keys(2.)
|
||||
.with_style(UiComponentStyles {
|
||||
margin: Some(Coords::default()),
|
||||
padding: Some(Coords::default()),
|
||||
border_width: Some(1.),
|
||||
background: Some(
|
||||
background_color
|
||||
.unwrap_or_else(|| blended_colors::neutral_3(theme))
|
||||
.into(),
|
||||
),
|
||||
font_color: Some(color.unwrap_or_else(|| theme.foreground().into_solid())),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_size: Some(font_size),
|
||||
width: Some(styles::keystroke_size(appearance)),
|
||||
height: Some(styles::keystroke_size(appearance)),
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.build()
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_agent_shortcuts_view(
|
||||
context: AgentShortcutsViewContext,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let hide_cloud_zero_state_items = context.is_cloud_agent && !context.has_submitted_first_prompt;
|
||||
|
||||
let mut shortcuts = vec![];
|
||||
|
||||
if !hide_cloud_zero_state_items {
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke {
|
||||
key: "!".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
text: "input shell command".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke {
|
||||
key: "/".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
text: "for slash commands".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke {
|
||||
key: "@".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
text: "for file paths and attaching other context".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
// Code review is not available for cloud agents.
|
||||
if !context.is_cloud_agent {
|
||||
if let Some(keystroke) = keybinding_name_to_keystroke(TOGGLE_RIGHT_PANEL_BINDING_NAME, app)
|
||||
{
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke,
|
||||
text: "open code review".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::AgentViewConversationListView.is_enabled() {
|
||||
if let Some(keystroke) =
|
||||
keybinding_name_to_keystroke(TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, app)
|
||||
{
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke,
|
||||
text: "toggle conversation list".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke::parse(cmd_or_ctrl_shift("y")).expect("is valid keystroke"),
|
||||
text: "search and continue conversations".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
// Use cloud keystroke (cmd+opt+enter) for cloud mode, regular keystroke (cmd+enter) otherwise.
|
||||
let new_conversation_keystroke = if context.is_cloud_agent {
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
|
||||
} else {
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
|
||||
};
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: new_conversation_keystroke.clone(),
|
||||
text: "start a new conversation".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
if !hide_cloud_zero_state_items {
|
||||
if let Some(keystroke) =
|
||||
keybinding_name_to_keystroke(TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, app)
|
||||
{
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke,
|
||||
text: "toggle auto-accept".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke {
|
||||
key: "c".to_owned(),
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
},
|
||||
text: "pause agent".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
text: "go back to terminal".into(),
|
||||
..Default::default()
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(8.)
|
||||
.with_children(shortcuts)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(16.)
|
||||
.with_padding_left(*terminal::view::PADDING_LEFT)
|
||||
.with_border(
|
||||
Border::new(1.)
|
||||
.with_sides(true, false, true, false)
|
||||
.with_border_color(blended_colors::neutral_2(appearance.theme())),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
|
||||
pub fn keystroke_size(appearance: &Appearance) -> f32 {
|
||||
font_size(appearance) + 2.
|
||||
}
|
||||
|
||||
pub fn font_size(appearance: &Appearance) -> f32 {
|
||||
appearance.monospace_font_size() - 2.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use warpui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
|
||||
server::telemetry::TelemetryEvent,
|
||||
terminal::input::buffer_model::InputBufferModel,
|
||||
};
|
||||
|
||||
/// Model responsible for managing state required to conditionally render the shortcuts view.
|
||||
pub struct AgentShortcutViewModel {
|
||||
is_shortcut_view_open: bool,
|
||||
}
|
||||
|
||||
impl AgentShortcutViewModel {
|
||||
pub fn new(
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
|
||||
if me.is_shortcut_view_open && !event.new_content.is_empty() {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
|
||||
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
is_shortcut_view_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_shortcut_view_open(&self) -> bool {
|
||||
self.is_shortcut_view_open
|
||||
}
|
||||
|
||||
pub fn open_shortcut_view(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_shortcut_view_visibility(true, ctx);
|
||||
}
|
||||
|
||||
pub fn hide_shortcut_view(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_shortcut_view_visibility(false, ctx);
|
||||
}
|
||||
|
||||
fn set_shortcut_view_visibility(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) {
|
||||
if is_open == self.is_shortcut_view_open {
|
||||
return;
|
||||
}
|
||||
self.is_shortcut_view_open = is_open;
|
||||
ctx.emit(AgentShortcutEvent::ToggledViewVisibility {
|
||||
is_visible: is_open,
|
||||
});
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::AgentShortcutsViewToggled {
|
||||
is_visible: is_open,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AgentShortcutViewModel {
|
||||
type Event = AgentShortcutEvent;
|
||||
}
|
||||
|
||||
pub enum AgentShortcutEvent {
|
||||
ToggledViewVisibility { is_visible: bool },
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
|
||||
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::terminal::model::{session::Session, TerminalModel};
|
||||
use crate::terminal::{
|
||||
color::{self, Colors},
|
||||
event_listener::ChannelEventListener,
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
|
||||
TerminalModel::new_for_test(
|
||||
block_size(),
|
||||
color::List::from(&Colors::default()),
|
||||
ChannelEventListener::new_for_test(),
|
||||
Arc::new(Background::default()),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
startup_path.map(PathBuf::from),
|
||||
)
|
||||
}
|
||||
|
||||
fn prebootstrap_terminal_with_startup_path(startup_path: &str) -> TerminalModel {
|
||||
let mut terminal = terminal_with_startup_path(Some(startup_path));
|
||||
terminal.block_list_mut().reinit_shell();
|
||||
terminal
|
||||
}
|
||||
#[test]
|
||||
fn format_session_location_shows_path_only_for_local_sessions() {
|
||||
let session = Session::test();
|
||||
let formatted = format_session_location(&session, Some("/Users/alice/repo"));
|
||||
assert_eq!(formatted, Some("/Users/alice/repo".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_session_location_shows_user_host_for_remote_sessions() {
|
||||
let session = Session::test_remote();
|
||||
let formatted =
|
||||
format_session_location(&session, Some("/Users/alice/repo")).expect("path exists");
|
||||
assert!(formatted.starts_with(&format!("{}@{}:", session.user(), session.hostname())));
|
||||
assert!(formatted.ends_with("/Users/alice/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_session_location_preserves_windows_style_paths() {
|
||||
let session = Session::test_remote();
|
||||
let formatted =
|
||||
format_session_location(&session, Some(r"C:\Users\alice\repo")).expect("path exists");
|
||||
assert!(formatted.starts_with(&format!("{}@{}:", session.user(), session.hostname())));
|
||||
assert!(formatted.ends_with(r"C:\Users\alice\repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_session_location_returns_none_when_path_missing() {
|
||||
let session = Session::test_remote();
|
||||
let formatted = format_session_location(&session, None);
|
||||
assert_eq!(formatted, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_working_directory_abbreviates_home_directory() {
|
||||
let display = display_working_directory(Some("/Users/alice"), Some("/Users/alice"));
|
||||
assert_eq!(display, Some("~".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_working_directory_abbreviates_subdirectory_under_home() {
|
||||
let display = display_working_directory(Some("/Users/alice/repo"), Some("/Users/alice"));
|
||||
assert_eq!(display, Some("~/repo".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.precmd(PrecmdValue {
|
||||
pwd: Some("/active/path".to_owned()),
|
||||
session_id: Some(0),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, Some("/active/path".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_session() {
|
||||
let terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, Some("/startup/path".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.ssh(SSHValue::default());
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_remote_session() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.init_shell(InitShellValue {
|
||||
session_id: 123.into(),
|
||||
shell: "zsh".to_owned(),
|
||||
hostname: "remote.example.com".to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_does_not_use_startup_path_after_bootstrap() {
|
||||
let terminal = terminal_with_startup_path(Some("/startup/path"));
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_updates_section_renders_when_all_conditions_are_true() {
|
||||
assert!(should_render_oz_updates_section(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_updates_section_does_not_render_when_setting_is_disabled() {
|
||||
assert!(!should_render_oz_updates_section(true, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_updates_section_does_not_render_without_updates() {
|
||||
assert!(!should_render_oz_updates_section(true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oz_updates_section_does_not_render_when_feature_flag_is_disabled() {
|
||||
assert!(!should_render_oz_updates_section(false, true, true));
|
||||
}
|
||||
Reference in New Issue
Block a user