Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,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(
&current_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)
}
}
+139
View File
@@ -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));
}