Files
galaxy/app/src/context_chips/display_chip.rs
T

1886 lines
69 KiB
Rust

use std::borrow::Cow;
use std::sync::Arc;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::prompt::plan_and_todo_list::{PlanAndTodoListEvent, PlanAndTodoListView};
use crate::ai::{
blocklist::{BlocklistAIContextModel, BlocklistAIInputModel},
document::ai_document_model::{AIDocumentId, AIDocumentVersion},
};
use crate::code::editor::{add_color, remove_color};
use crate::code_review::code_review_view::CODE_REVIEW_TOOLTIP_TEXT;
use crate::code_review::diff_state::DiffStats;
use crate::context_chips::node_version_popup::{NodeVersionPopupEvent, NodeVersionPopupView};
use crate::context_chips::spacing;
use crate::settings::{AISettings, AISettingsChangedEvent, InputSettings};
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::input::{MenuPositioning, MenuPositioningProvider};
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::bindings::keybinding_name_to_display_string;
use crate::util::truncation::truncate_from_beginning;
use crate::view_components::action_button::{ActionButtonTheme, NakedTheme};
use crate::view_components::{FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::path::PathBuf;
use warp_core::ui::theme::Fill;
use warp_core::{features::FeatureFlag, ui::theme::color::internal_colors};
use warpui::elements::Empty;
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponentStyles;
use warpui::ui_components::components::{Coords, UiComponent};
use warpui::{
elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
},
fonts::{Cache, FamilyId, Properties, Weight},
AppContext, Element, Entity, EntityId, Gradient, ModelHandle, SingletonEntity, TypedActionView,
View, ViewContext, ViewHandle,
};
use crate::appearance::Appearance;
use crate::completer::SessionContext;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use super::{
agent_view_chip_color,
directory_fetcher::{DirectoryFetcher, DirectoryFetcherEvent, DirectoryItem, DirectoryType},
display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
},
github_pr_display_text_from_url, render_text_from_kind, ChipResult, ContextChipKind,
};
use crate::workspace::view::TOGGLE_RIGHT_PANEL_BINDING_NAME;
/// Helper function to render git diff stats content (file icon or +- icons, file count, bullet, +/- counts)
/// Used by both the context chips and the AI control panel
pub fn render_git_diff_stats_content(
line_changes: &GitLineChanges,
icon_size: f32,
font_family: FamilyId,
font_size: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let mut git_content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
// Determine if there are any changes
let has_changes = line_changes.lines_added > 0 || line_changes.lines_removed > 0;
// Add icon based on whether there are changes
let icon_element = if has_changes {
// Use file icon when there are changes
Icon::File
.to_warpui_icon(Fill::Solid(internal_colors::neutral_6(theme)))
.finish()
} else {
// Use diff icon when there are no changes
Icon::Diff
.to_warpui_icon(Fill::Solid(internal_colors::neutral_6(theme)))
.finish()
};
git_content.add_child(
Container::new(
ConstrainedBox::new(icon_element)
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
let mut counts_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
// Add file count
counts_row.add_child(
Text::new_inline(
line_changes.files_changed.to_string(),
font_family,
font_size,
)
.with_color(Fill::Solid(internal_colors::neutral_6(theme)).into())
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
);
// Only add bullet separator if there are changes to display
if has_changes {
counts_row.add_child(
Text::new_inline(" • ", font_family, font_size)
.with_color(Fill::Solid(internal_colors::neutral_6(theme)).into())
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
);
}
// Display git line changes using the parsed struct data
if line_changes.lines_added > 0 {
// Add green text for additions
counts_row.add_child(
Text::new_inline(
format!("+{}", line_changes.lines_added),
font_family,
font_size,
)
.with_color(add_color(appearance))
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
);
}
if line_changes.lines_removed > 0 {
// Add space if we already have additions
if line_changes.lines_added > 0 {
counts_row.add_child(Text::new_inline(" ", font_family, font_size).finish());
}
// Add red text for deletions
counts_row.add_child(
Text::new_inline(
format!("-{}", line_changes.lines_removed),
font_family,
font_size,
)
.with_color(remove_color(appearance))
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
);
}
git_content.add_child(counts_row.finish());
git_content.finish()
}
const PROMPT_CHIP_DISPLAY_ID: &str = "PromptChipDisplay";
const DROP_SHADOW_COLOR: ColorU = ColorU {
r: 0,
g: 0,
b: 0,
a: 32,
};
const CHIP_MARGIN_RIGHT: f32 = 8.;
const UDI_CHIP_MAX_NUM_CHARACTERS: usize = 40;
const CHIP_CORNER_RADIUS: f32 = 4.0;
pub(crate) const CHIP_BORDER_WIDTH: f32 = 1.0;
/// Inner rounded corners are 1px smaller than the outer border radius
const CHIP_INNER_CORNER_RADIUS: f32 = CHIP_CORNER_RADIUS - CHIP_BORDER_WIDTH;
/// Standard positioning for tooltip overlays on UDI chips
fn udi_tooltip_positioning() -> OffsetPositioning {
OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
)
}
/// Configuration for creating a unified UDI chip
pub(crate) struct UdiChipConfig {
/// The icon to display
icon: Option<Icon>,
keystroke: Option<Keystroke>,
/// The color for both icon and text
color: ColorU,
/// The text content to display
text: String,
/// Whether to truncate text to UDI_CHIP_MAX_NUM_CHARACTERS
truncate_text: bool,
border_override: Option<Border>,
is_in_agent_view: bool,
/// When `true`, the chip paints its hover background instead of its
/// default background. The chip's background is opaque, so callers must
/// toggle this from their `Hoverable` rather than wrap the chip in an
/// outer container (which would be occluded).
hovered: bool,
}
impl UdiChipConfig {
pub(crate) fn new(color: ColorU, text: String) -> Self {
Self {
icon: None,
keystroke: None,
color,
text,
truncate_text: true,
border_override: None,
is_in_agent_view: false,
hovered: false,
}
}
pub(crate) fn new_with_icon(icon: Icon, color: ColorU, text: String) -> Self {
Self {
icon: Some(icon),
keystroke: None,
color,
text,
truncate_text: true,
border_override: None,
is_in_agent_view: false,
hovered: false,
}
}
fn new_with_keystroke(color: ColorU, text: String, keystroke: Keystroke) -> Self {
Self {
icon: None,
keystroke: Some(keystroke),
color,
text,
truncate_text: true,
border_override: None,
is_in_agent_view: false,
hovered: false,
}
}
pub(crate) fn with_truncate_text(mut self, truncate: bool) -> Self {
self.truncate_text = truncate;
self
}
pub(crate) fn with_border_override(mut self, border: Border) -> Self {
self.border_override = Some(border);
self
}
pub(crate) fn with_hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
}
fn for_agent_view(mut self) -> Self {
self.is_in_agent_view = true;
self
}
}
#[derive(Debug, Clone)]
pub enum DisplayChipAction {
CloseMenu,
ToggleMenu,
ToggleCodeReview,
OpenBranchSelector,
OpenGithubPullRequest(String),
}
pub struct DisplayChip {
mouse_state: MouseStateHandle,
diff_stats_mouse_state: MouseStateHandle,
text: String,
chip_kind: ContextChipKind,
display_chip_kind: DisplayChipKind,
next_chip_kind: Option<ContextChipKind>,
first_on_click_value: Option<String>,
quota_reset_popup: ViewHandle<FeaturePopup>,
session_context: Option<SessionContext>,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
agent_view_controller: ModelHandle<AgentViewController>,
is_shared_session_viewer: bool,
is_in_agent_view: bool,
/// Optional because `DisplayChip` sometimes should be disabled, depending on if it is in an ambient agent view.
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
/// Cached display string for the code review keybinding.
code_review_keybinding: Option<String>,
/// The terminal view this chip belongs to, used to check CLI agent session state.
terminal_view_id: EntityId,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitLineChanges {
pub files_changed: u32,
pub lines_added: u32,
pub lines_removed: u32,
}
impl GitLineChanges {
/// Convert GitDiffData to GitLineChanges
pub fn from_diff_stats(diff_stats: &DiffStats) -> Self {
Self {
files_changed: diff_stats.files_changed as u32,
lines_added: diff_stats.total_additions as u32,
lines_removed: diff_stats.total_deletions as u32,
}
}
/// Parse git diff --shortstat output into GitLineChanges struct
/// Input example: " 1 file changed, 2 insertions(+), 17 deletions(-)"
pub fn parse_from_git_output(raw_output: &str) -> Option<Self> {
let line = raw_output.trim();
if line.is_empty() {
return None;
}
let mut files_changed = 0;
let mut lines_added = 0;
let mut lines_removed = 0;
let words: Vec<&str> = line.split_whitespace().collect();
for (i, word) in words.iter().enumerate() {
if let Ok(num) = word.parse::<u32>() {
if let Some(next_word) = words.get(i + 1) {
if next_word.starts_with("file") {
files_changed = num;
} else if next_word.starts_with("insertion") {
lines_added = num;
} else if next_word.starts_with("deletion") {
lines_removed = num;
}
}
}
}
Some(Self {
files_changed,
lines_added,
lines_removed,
})
}
}
#[derive(Debug, Clone)]
pub enum DisplayChipKind {
Text,
WorkingDirectory {
show_menu: bool,
menu_open: bool,
menu: ViewHandle<DisplayChipMenu>,
directory_fetcher: ModelHandle<DirectoryFetcher>,
},
Ssh,
Subshell,
VirtualEnvironment,
CondaEnvironment,
NodeVersion {
popup_open: bool,
popup: ViewHandle<crate::context_chips::node_version_popup::NodeVersionPopupView>,
},
AgentPlanAndTodoList {
plan_and_todo_list: ViewHandle<PlanAndTodoListView>,
},
GitBranch {
menu_open: bool,
menu: ViewHandle<DisplayChipMenu>,
},
GithubPullRequest,
GitDiffStats {
line_changes_info: Option<GitLineChanges>,
},
}
impl DisplayChipKind {
pub fn has_open_menu(&self) -> bool {
match self {
DisplayChipKind::WorkingDirectory { menu_open, .. } => *menu_open,
DisplayChipKind::NodeVersion { popup_open, .. } => *popup_open,
DisplayChipKind::GitBranch { menu_open, .. } => *menu_open,
DisplayChipKind::GithubPullRequest
| DisplayChipKind::GitDiffStats { .. }
| DisplayChipKind::Text
| DisplayChipKind::Ssh
| DisplayChipKind::Subshell
| DisplayChipKind::VirtualEnvironment
| DisplayChipKind::CondaEnvironment
| DisplayChipKind::AgentPlanAndTodoList { .. } => false,
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum SelectAction {
Prev,
Next,
Index(usize),
}
#[derive(Debug, Clone)]
pub enum MenuItemType {
GitBranch,
File,
TextFile,
Directory,
}
#[derive(Debug, Clone)]
pub struct MenuItem {
pub name: String,
pub item_type: MenuItemType,
}
/// Configuration for creating a DisplayChip
#[derive(Clone)]
pub struct DisplayChipConfig {
pub ai_input_model: ModelHandle<BlocklistAIInputModel>,
pub ai_context_model: ModelHandle<BlocklistAIContextModel>,
pub terminal_view_id: EntityId,
pub menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
pub session_context: Option<SessionContext>,
pub current_repo_path: Option<PathBuf>,
pub model_events: ModelHandle<ModelEventDispatcher>,
pub is_shared_session_viewer: bool,
pub agent_view_controller: ModelHandle<AgentViewController>,
/// Optional because `DisplayChip` sometimes should be disabled, depending on if it is in an ambient agent view.
pub ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
}
#[derive(Debug, Clone)]
pub struct GitBranch(String);
impl GenericMenuItem for GitBranch {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
self.0.clone()
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
Some(Icon::GitBranch)
}
fn action_data(&self) -> String {
self.0.clone()
}
}
impl DisplayChip {
/// Convert MenuPositioning to appropriate anchor pair for overlay positioning
fn positioning_to_anchors(positioning: MenuPositioning) -> (ParentAnchor, ChildAnchor) {
match positioning {
MenuPositioning::BelowInputBox => (ParentAnchor::BottomLeft, ChildAnchor::TopLeft),
MenuPositioning::AboveInputBox => (ParentAnchor::TopLeft, ChildAnchor::BottomLeft),
}
}
pub fn new(
ctx: &mut ViewContext<Self>,
chip_result: ChipResult,
next_chip_kind: Option<ContextChipKind>,
config: DisplayChipConfig,
) -> Self {
Self::new_internal(chip_result, next_chip_kind, config, false, ctx)
}
pub fn new_for_agent_view(
chip_result: ChipResult,
next_chip_kind: Option<ContextChipKind>,
config: DisplayChipConfig,
ctx: &mut ViewContext<Self>,
) -> Self {
Self::new_internal(chip_result, next_chip_kind, config, true, ctx)
}
fn new_internal(
chip_result: ChipResult,
next_chip_kind: Option<ContextChipKind>,
config: DisplayChipConfig,
is_in_agent_view: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
// Re-render this chip whenever Agent Mode state changes so UDI font/color updates
// immediately on enter/exit.
ctx.subscribe_to_model(&config.agent_view_controller, |_me, _model, _event, ctx| {
ctx.notify();
});
let display_chip_kind = match chip_result.kind {
ContextChipKind::AgentPlanAndTodoList => {
let context_model = config.ai_context_model.clone();
let view_id = config.terminal_view_id;
let plan_and_todo_list = ctx.add_typed_action_view(|ctx| {
PlanAndTodoListView::new(
context_model,
config.menu_positioning_provider.clone(),
view_id,
is_in_agent_view,
ctx,
)
});
ctx.subscribe_to_view(&plan_and_todo_list, |_me, _, event, ctx| match event {
PlanAndTodoListEvent::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(PromptDisplayChipEvent::OpenAIDocument {
document_id: *document_id,
document_version: *document_version,
});
}
});
DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list }
}
ContextChipKind::ShellGitBranch => {
// Convert git branch strings to GitBranch items
let git_branch_items: Vec<GitBranch> = chip_result
.on_click_values
.iter()
.map(|branch_name| GitBranch(branch_name.clone()))
.collect();
let menu_view = ctx.add_typed_action_view(move |ctx| {
DisplayChipMenu::new(
git_branch_items,
None, // No fixed footer for git branches
ChipMenuType::Branches,
ctx,
)
});
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
PromptDisplayMenuEvent::MenuAction(generic_event) => {
let Some(git_branch) = generic_event
.action_item
.as_any()
.downcast_ref::<GitBranch>()
else {
log::warn!("MenuAction event should contain ActionItem action item");
return;
};
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_git_branch_command(&git_branch.name()),
));
me.close_git_branch_menu(ctx);
ctx.notify();
}
PromptDisplayMenuEvent::CloseMenu => {
me.close_git_branch_menu(ctx);
ctx.emit(PromptDisplayChipEvent::ToggleMenu { open: false });
ctx.notify();
}
});
DisplayChipKind::GitBranch {
menu_open: false,
menu: menu_view,
}
}
ContextChipKind::GitDiffStats => DisplayChipKind::GitDiffStats {
line_changes_info: None,
},
ContextChipKind::GithubPullRequest => DisplayChipKind::GithubPullRequest,
ContextChipKind::WorkingDirectory => {
let dir_path = chip_result
.value
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
let directory_fetcher = ctx.add_model(|ctx| {
DirectoryFetcher::new(dir_path.clone(), config.session_context.clone(), ctx)
});
let menu_view = ctx.add_typed_action_view(move |ctx| {
DisplayChipMenu::new(
Vec::<DirectoryItem>::new(),
Some(FixedFooter::new(Arc::new(DirectoryItem {
name: ".. (Parent Directory)".to_string(),
directory_type: DirectoryType::NavigateToParent,
}))), // Show parent directory option
ChipMenuType::Directories,
ctx,
)
});
// Subscribe to DirectoryFetcher events to update menu
let directory_fetcher_clone = directory_fetcher.clone();
ctx.subscribe_to_model(
&directory_fetcher,
move |display_chip, _model, event, ctx| {
match event {
DirectoryFetcherEvent::DirectoryContentsUpdated => {
// Update the existing menu with new directory contents
if let DisplayChipKind::WorkingDirectory { menu, .. } =
&mut display_chip.display_chip_kind
{
let new_files = directory_fetcher_clone
.read(ctx, |fetcher, _| fetcher.cached_files().to_vec());
// Update the existing menu with new content instead of recreating it
menu.update(ctx, |menu_view, menu_ctx| {
// Update the menu items using the new method
menu_view.update_menu_items(new_files, menu_ctx);
});
}
ctx.notify();
}
DirectoryFetcherEvent::FetchStarted => {
log::debug!("Directory fetch started");
}
DirectoryFetcherEvent::FetchCompleted { success } => {
log::debug!("Directory fetch completed: success={success}");
}
}
},
);
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
PromptDisplayMenuEvent::MenuAction(generic_event) => {
let action_item = generic_event.action_item.clone();
let Some(directory_item) =
action_item.as_any().downcast_ref::<DirectoryItem>()
else {
log::warn!("MenuAction event should contain DirectoryItem action item");
return;
};
match directory_item.directory_type {
DirectoryType::Directory => {
// For directories, navigate action is change directory
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_change_directory_command(&directory_item.name),
));
me.close_working_directory_menu(ctx);
ctx.notify();
}
DirectoryType::TextFile => {
// For text files, secondary action is open in code editor
ctx.emit(PromptDisplayChipEvent::OpenTextFileInCodeEditor(
directory_item.name.clone(),
));
me.close_working_directory_menu(ctx);
ctx.notify();
}
DirectoryType::OtherFile => {
// For other files, primary action is default open
ctx.emit(PromptDisplayChipEvent::OpenFile(
directory_item.name.clone(),
));
me.close_working_directory_menu(ctx);
ctx.notify();
}
DirectoryType::NavigateToParent => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_change_directory_command(".."),
));
me.close_working_directory_menu(ctx);
ctx.notify();
}
}
}
PromptDisplayMenuEvent::CloseMenu => {
me.close_working_directory_menu(ctx);
ctx.emit(PromptDisplayChipEvent::ToggleMenu { open: false });
ctx.notify();
}
});
DisplayChipKind::WorkingDirectory {
show_menu: true,
menu_open: false,
menu: menu_view,
directory_fetcher,
}
}
ContextChipKind::Ssh => DisplayChipKind::Ssh,
ContextChipKind::Subshell => DisplayChipKind::Subshell,
ContextChipKind::VirtualEnvironment => DisplayChipKind::VirtualEnvironment,
ContextChipKind::CondaEnvironment => DisplayChipKind::CondaEnvironment,
ContextChipKind::NodeVersion => {
let current_version = chip_result.value.as_ref().map(|v| v.to_string());
let model_events = &config.model_events;
let popup_view = ctx.add_typed_action_view(move |ctx| {
NodeVersionPopupView::new(current_version, model_events, ctx)
});
ctx.subscribe_to_view(&popup_view, |me, _, event, ctx| match event {
NodeVersionPopupEvent::Close => {
me.close_node_version_popup(ctx);
ctx.focus_self();
}
NodeVersionPopupEvent::SelectVersion { version } => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(format!(
"nvm use {version}"
)));
me.close_node_version_popup(ctx);
ctx.focus_self();
}
NodeVersionPopupEvent::InstallNvm => {
ctx.emit(PromptDisplayChipEvent::RunAgentQuery(if cfg!(windows) {
// nvm-windows has documented issues when installed alongside an existing Node.js installation.
// https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#star-star-uninstall-any-pre-existing-node-installations-star-star
// Prompt the agent to remove this first.
"Uninstall existing Node.js installation and install nvm for me"
.to_string()
} else {
"Install nvm for me".to_string()
}));
me.close_node_version_popup(ctx);
}
NodeVersionPopupEvent::InstallLatestNodeVersion => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
"nvm install node".to_string(),
));
me.close_node_version_popup(ctx);
}
});
DisplayChipKind::NodeVersion {
popup_open: false,
popup: popup_view,
}
}
_ => DisplayChipKind::Text,
};
let quota_reset_popup = ctx.add_typed_action_view(|_| {
FeaturePopup::alert_icon(NewFeaturePopupLabel::FromString(
"Monthly AI credits reset!".to_string(),
))
});
ctx.subscribe_to_view(&quota_reset_popup, |_, _, event, ctx| match event {
NewFeaturePopupEvent::Dismissed => {
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
ai_settings.mark_quota_banner_as_dismissed(ctx);
ctx.notify();
});
ctx.notify();
}
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |_, _, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::AIRequestQuotaInfoSetting { .. }
) {
ctx.notify();
}
});
// Subscribe to ambient agent model changes to re-render when the state changes
if let Some(ref ambient_agent_model) = config.ambient_agent_view_model {
ctx.subscribe_to_model(ambient_agent_model, |_, _, _, ctx| {
ctx.notify();
});
}
// Cache the code review keybinding and subscribe to changes.
let code_review_keybinding =
keybinding_name_to_display_string(TOGGLE_RIGHT_PANEL_BINDING_NAME, ctx);
ctx.subscribe_to_model(
&KeybindingChangedNotifier::handle(ctx),
|me, _, event, ctx| {
let KeybindingChangedEvent::BindingChanged {
binding_name,
new_trigger,
} = event;
if binding_name == TOGGLE_RIGHT_PANEL_BINDING_NAME {
me.code_review_keybinding = new_trigger.as_ref().map(|k| k.displayed());
ctx.notify();
}
},
);
Self {
mouse_state: Default::default(),
diff_stats_mouse_state: Default::default(),
text: chip_result.value.map(|v| v.to_string()).unwrap_or_default(),
chip_kind: chip_result.kind,
display_chip_kind,
next_chip_kind,
first_on_click_value: chip_result.on_click_values.first().cloned(),
quota_reset_popup,
session_context: config.session_context,
menu_positioning_provider: config.menu_positioning_provider,
is_shared_session_viewer: config.is_shared_session_viewer,
agent_view_controller: config.agent_view_controller.clone(),
is_in_agent_view,
ambient_agent_view_model: config.ambient_agent_view_model,
code_review_keybinding,
terminal_view_id: config.terminal_view_id,
}
}
/// Returns `true` when a CLI agent session is active for this chip's terminal,
/// meaning interactive behaviors (menus, hover, click) should be suppressed.
fn is_cli_agent_session_active(&self, app: &AppContext) -> bool {
CLIAgentSessionsModel::as_ref(app)
.session(self.terminal_view_id)
.is_some()
}
fn close_node_version_popup(&mut self, ctx: &mut ViewContext<'_, DisplayChip>) {
if let DisplayChipKind::NodeVersion { popup_open, .. } = &mut self.display_chip_kind {
*popup_open = false;
ctx.notify();
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn chip_kind(&self) -> &ContextChipKind {
&self.chip_kind
}
pub fn display_chip_kind(&self) -> &DisplayChipKind {
&self.display_chip_kind
}
pub fn first_on_click_value(&self) -> Option<&String> {
self.first_on_click_value.as_ref()
}
pub fn close_git_branch_menu(&mut self, ctx: &mut ViewContext<Self>) {
if let DisplayChipKind::GitBranch { menu_open, menu } = &mut self.display_chip_kind {
*menu_open = false;
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
}
}
pub fn close_working_directory_menu(&mut self, ctx: &mut ViewContext<Self>) {
if let DisplayChipKind::WorkingDirectory {
menu_open, menu, ..
} = &mut self.display_chip_kind
{
*menu_open = false;
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
}
}
/// Try to focus an open menu if this chip has one. Returns true if a menu was focused.
pub fn try_focus_open_menu(&self, ctx: &mut ViewContext<Self>) -> bool {
match &self.display_chip_kind {
DisplayChipKind::WorkingDirectory {
menu_open, menu, ..
} => {
if *menu_open {
ctx.focus(menu);
return true;
}
}
DisplayChipKind::GitBranch { menu_open, menu } => {
if *menu_open {
ctx.focus(menu);
return true;
}
}
DisplayChipKind::GitDiffStats { .. }
| DisplayChipKind::Text
| DisplayChipKind::Ssh
| DisplayChipKind::Subshell
| DisplayChipKind::VirtualEnvironment
| DisplayChipKind::CondaEnvironment
| DisplayChipKind::NodeVersion { .. }
| DisplayChipKind::AgentPlanAndTodoList { .. }
| DisplayChipKind::GithubPullRequest => {}
}
false
}
pub fn maybe_set_git_line_changes_info(
&mut self,
git_line_changes_info: Option<GitLineChanges>,
) {
if let DisplayChipKind::GitDiffStats {
line_changes_info, ..
} = &mut self.display_chip_kind
{
*line_changes_info = git_line_changes_info;
}
}
/// Update the session context (useful when it becomes available later)
pub fn update_session_context(
&mut self,
session_context: Option<SessionContext>,
ctx: &mut ViewContext<Self>,
) {
self.session_context = session_context.clone();
// If this is a working directory chip, update the DirectoryFetcher's session context
if let DisplayChipKind::WorkingDirectory {
directory_fetcher, ..
} = &self.display_chip_kind
{
directory_fetcher.update(ctx, |fetcher, model_ctx| {
fetcher.update_session_context(session_context, model_ctx);
});
}
}
pub fn em_width(&self, font_cache: &Cache, appearance: &Appearance) -> f32 {
font_cache.em_width(
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
}
fn render_keystroke_with_label(
keystroke: &Option<Keystroke>,
label: impl Into<Cow<'static, str>>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let prompt_height = app.font_cache().line_height(
appearance.monospace_font_size(),
DEFAULT_UI_LINE_HEIGHT_RATIO,
);
let theme = appearance.theme();
let keybinding_style = UiComponentStyles {
height: Some(prompt_height),
width: Some(prompt_height),
font_size: Some(appearance.monospace_font_size() - 4.),
font_color: Some(blended_colors::text_main(theme, theme.surface_2())),
background: Some(theme.surface_2().into()),
foreground: Some(theme.foreground().into()),
padding: Some(Coords::default()),
margin: Some(Coords {
left: 2.,
right: 2.,
..Default::default()
}),
..Default::default()
};
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(keystroke) = keystroke {
row.add_child(
Container::new(
appearance
.ui_builder()
.keyboard_shortcut(keystroke)
.with_style(keybinding_style)
.build()
.finish(),
)
.finish(),
);
}
row.add_child(
Text::new_inline(
label,
appearance.monospace_font_family(),
appearance.monospace_font_size(),
)
.with_color(blended_colors::text_disabled(theme, theme.background()))
.finish(),
);
row.finish()
}
pub fn should_render(&self, app: &AppContext) -> bool {
match &self.display_chip_kind {
DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list } => {
plan_and_todo_list.as_ref(app).should_render(app)
}
_ => true,
}
}
fn git_branch_chip(
&self,
menu_open: bool,
menu: &ViewHandle<DisplayChipMenu>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let font_color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_green()
};
let is_interactive =
!self.is_shared_session_viewer && !self.is_cli_agent_session_active(app);
let is_in_agent_view = self.is_in_agent_view;
let chip_text = self.text.clone();
let hover = Hoverable::new(self.mouse_state.clone(), move |state| {
let hovered = state.is_hovered() && is_interactive;
let mut config =
UdiChipConfig::new_with_icon(Icon::GitBranch, font_color, chip_text.clone())
.with_hovered(hovered);
if is_in_agent_view {
config = config.for_agent_view();
}
let chip_element = render_udi_chip(config, appearance);
let mut stack = Stack::new().with_child(chip_element);
if state.is_hovered() && is_interactive && !menu_open {
let tool_tip = appearance
.ui_builder()
.tool_tip("Change git branch".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
stack.finish()
});
let hover = if !is_interactive {
hover.finish()
} else {
hover
.on_click(|ctx, _app, _position| {
ctx.dispatch_typed_action(DisplayChipAction::OpenBranchSelector);
})
.with_cursor(Cursor::PointingHand)
.finish()
};
let mut stack = Stack::new().with_child(hover);
if menu_open {
let positioning = self.menu_positioning_provider.menu_position(app);
let (parent_anchor, child_anchor) = Self::positioning_to_anchors(positioning);
let offset = match positioning {
MenuPositioning::BelowInputBox => vec2f(0., 4.),
MenuPositioning::AboveInputBox => vec2f(0., -4.),
};
stack.add_positioned_overlay_child(
ChildView::new(menu).finish(),
OffsetPositioning::offset_from_parent(
offset,
ParentOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
),
);
}
stack.finish()
}
fn github_pull_request_chip(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let font_color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_green()
};
let chip_text =
github_pr_display_text_from_url(&self.text).unwrap_or_else(|| self.text.clone());
let url = self.text.clone();
let is_in_agent_view = self.is_in_agent_view;
let hover = Hoverable::new(self.mouse_state.clone(), move |state| {
let mut config =
UdiChipConfig::new_with_icon(Icon::Github, font_color, chip_text.clone())
.with_hovered(state.is_hovered());
if is_in_agent_view {
config = config.for_agent_view();
}
let chip_element = render_udi_chip(config, appearance);
let mut stack = Stack::new().with_child(chip_element);
if state.is_hovered() {
let tool_tip = appearance
.ui_builder()
.tool_tip("View pull request".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
stack.finish()
});
hover
.on_click(move |ctx, _app, _position| {
if !url.trim().is_empty() {
ctx.dispatch_typed_action(DisplayChipAction::OpenGithubPullRequest(
url.clone(),
));
}
})
.with_cursor(Cursor::PointingHand)
.finish()
}
fn git_diff_stats_chip(
&self,
line_changes_info: &Option<GitLineChanges>,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let Some(line_changes_info) = line_changes_info else {
return None;
};
if self.is_shared_session_viewer {
return None;
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let udi_icon_size = udi_icon_size(appearance, app);
let font_size = udi_font_size(appearance);
let font_family = if self.is_in_agent_view {
appearance.ui_font_family()
} else if FeatureFlag::AgentView.is_enabled() {
appearance.monospace_font_family()
} else {
appearance.ui_font_family()
};
let git_diff_stats_content = render_git_diff_stats_content(
line_changes_info,
udi_icon_size,
font_family,
font_size,
appearance,
);
let is_local_session = self
.session_context
.as_ref()
.map(|ctx| ctx.session.is_local())
.unwrap_or(true);
let diff_stats_display = if is_local_session {
// Get the keybinding for the tooltip
let code_review_keybinding = self.code_review_keybinding.clone().unwrap_or_default();
Hoverable::new(self.diff_stats_mouse_state.clone(), |state| {
let base_container = Container::new(git_diff_stats_content)
.with_vertical_padding(2.)
.with_horizontal_padding(4.);
let base_container = if state.is_hovered() {
base_container
.with_background(appearance.theme().surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
CHIP_INNER_CORNER_RADIUS,
)))
.finish()
} else {
base_container.finish()
};
let mut stack = Stack::new().with_child(base_container);
if state.is_hovered() {
let tool_tip = appearance
.ui_builder()
.tool_tip_with_sublabel(
CODE_REVIEW_TOOLTIP_TEXT.to_string(),
code_review_keybinding.clone(),
)
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
stack.finish()
})
.on_click(|ctx, _app, _position| {
ctx.dispatch_typed_action(DisplayChipAction::ToggleCodeReview);
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
// Remote session: chip is non-interactive (no tooltip, no click handler)
Container::new(git_diff_stats_content)
.with_vertical_padding(2.)
.with_horizontal_padding(4.)
.finish()
};
let content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(diff_stats_display)
.finish();
let button = Container::new(content)
.with_background(theme.surface_1())
.with_border(Border::all(1.0).with_border_color(internal_colors::neutral_3(theme)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CHIP_CORNER_RADIUS)))
.finish();
Some(button)
}
fn working_directory_chip(
&self,
show_menu: bool,
menu: &ViewHandle<DisplayChipMenu>,
menu_open: bool,
app: &AppContext,
) -> Box<dyn Element> {
// Note: Menu contents are updated via model subscriptions when directory contents change
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
// Check if we're in an ambient agent conversation.
// If so, the directory chip should be non-interactive.
let is_in_active_ambient_agent = self
.ambient_agent_view_model
.as_ref()
.map(|model| {
let m = model.as_ref(app);
m.is_ambient_agent() && !m.is_configuring_ambient_agent()
})
.unwrap_or(false);
let mut stack = Stack::new();
// Menu is only allowed when the caller requests it and we're not in an active ambient
// agent session or CLI agent session.
let is_cli_agent_active = self.is_cli_agent_session_active(app);
let allow_show_menu = show_menu && !is_in_active_ambient_agent && !is_cli_agent_active;
let button = if allow_show_menu {
let chip_text = self.text.clone();
let font_color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
theme.ansi_fg_cyan()
};
let is_in_agent_view = self.is_in_agent_view;
Hoverable::new(self.mouse_state.clone(), move |state| {
let hovered = !menu_open && state.is_hovered();
let mut config =
UdiChipConfig::new_with_icon(Icon::Folder, font_color, chip_text.clone())
.with_hovered(hovered);
if is_in_agent_view {
config = config.for_agent_view();
}
let chip_element = render_udi_chip(config, appearance);
let mut stack = Stack::new().with_child(chip_element);
if state.is_hovered() {
let tool_tip = appearance
.ui_builder()
.tool_tip("Change working directory".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
stack.finish()
})
.on_click(|ctx, _app, _position| {
ctx.dispatch_typed_action(DisplayChipAction::ToggleMenu);
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
// Non-interactive chip (either show_menu is false or in active ambient agent)
let font_color = if self.is_in_agent_view {
// Use disabled text color when in active ambient agent
if is_in_active_ambient_agent {
theme
.disabled_text_color(blended_colors::neutral_1(theme).into())
.into_solid()
} else {
// In agent view but the chip is non-interactive for reasons other than an active
// ambient agent session. Keep the normal agent-view subtext styling (not disabled).
agent_view_chip_color(appearance)
}
} else {
theme.ansi_fg_cyan()
};
let chip_text = self.text.clone();
let is_in_agent_view = self.is_in_agent_view;
Hoverable::new(self.mouse_state.clone(), move |state| {
let mut config =
UdiChipConfig::new_with_icon(Icon::Folder, font_color, chip_text.clone());
if is_in_agent_view {
config = config.for_agent_view();
}
let chip_element = render_udi_chip(config, appearance);
let mut stack = Stack::new().with_child(chip_element);
if state.is_hovered() && !is_cli_agent_active {
let tool_tip = appearance
.ui_builder()
.tool_tip("Working directory".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
stack.finish()
})
.with_cursor(Cursor::Arrow)
.finish()
};
stack.add_child(button);
if menu_open {
let positioning = self.menu_positioning_provider.menu_position(app);
let (parent_anchor, child_anchor) = Self::positioning_to_anchors(positioning);
let offset = match positioning {
MenuPositioning::BelowInputBox => vec2f(0., 4.),
MenuPositioning::AboveInputBox => vec2f(0., -4.),
};
stack.add_positioned_overlay_child(
ChildView::new(menu).finish(),
OffsetPositioning::offset_from_parent(
offset,
ParentOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
),
);
}
stack.finish()
}
fn ssh_chip(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_blue()
};
let mut config = UdiChipConfig::new_with_icon(Icon::User, color, self.text.clone());
if self.is_in_agent_view {
config = config.for_agent_view();
}
render_udi_chip(config, appearance)
}
fn subshell_chip(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_blue()
};
let mut config = UdiChipConfig::new_with_icon(Icon::Terminal, color, self.text.clone());
if self.is_in_agent_view {
config = config.for_agent_view();
}
render_udi_chip(config, appearance)
}
fn virtual_environment_chip(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_yellow()
};
let mut config = UdiChipConfig::new_with_icon(Icon::Terminal, color, self.text.clone());
if self.is_in_agent_view {
config = config.for_agent_view();
}
render_udi_chip(config, appearance)
}
fn conda_environment_chip(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let color = if self.is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_yellow()
};
let mut config = UdiChipConfig::new_with_icon(Icon::Terminal, color, self.text.clone());
if self.is_in_agent_view {
config = config.for_agent_view();
}
render_udi_chip(config, appearance)
}
fn node_version_chip(
&self,
popup: &ViewHandle<crate::context_chips::node_version_popup::NodeVersionPopupView>,
popup_open: bool,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let chip_text = self.text.clone();
let is_in_agent_view = self.is_in_agent_view;
let hoverable = Hoverable::new(self.mouse_state.clone(), move |state| {
let color = if is_in_agent_view {
agent_view_chip_color(appearance)
} else {
appearance.theme().ansi_fg_green()
};
let hovered = state.is_hovered() && !popup_open;
let mut config = UdiChipConfig::new_with_icon(Icon::NodeJS, color, chip_text.clone())
.with_hovered(hovered);
if is_in_agent_view {
config = config.for_agent_view();
}
render_udi_chip(config, appearance)
})
.on_click(|ctx, _app, _pos| {
ctx.dispatch_typed_action(DisplayChipAction::ToggleMenu);
})
.with_cursor(Cursor::PointingHand)
.finish();
let mut stack = Stack::new().with_child(hoverable);
if popup_open {
let positioning = self.menu_positioning_provider.menu_position(app);
let (parent_anchor, child_anchor) = Self::positioning_to_anchors(positioning);
let offset = match positioning {
MenuPositioning::BelowInputBox => vec2f(0., 4.),
MenuPositioning::AboveInputBox => vec2f(0., -4.),
};
stack.add_positioned_overlay_child(
ChildView::new(popup).finish(),
OffsetPositioning::offset_from_parent(
offset,
ParentOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
),
);
}
stack.finish()
}
fn render_chip(&self, app: &AppContext) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
let font_family = if self.is_in_agent_view || !FeatureFlag::AgentView.is_enabled() {
appearance.ui_font_family()
} else {
appearance.monospace_font_family()
};
let font_size = udi_font_size(appearance);
match &self.display_chip_kind {
DisplayChipKind::WorkingDirectory {
show_menu,
menu,
menu_open,
..
} => Some(self.working_directory_chip(*show_menu, menu, *menu_open, app)),
DisplayChipKind::Ssh => Some(self.ssh_chip(app)),
DisplayChipKind::Subshell => Some(self.subshell_chip(app)),
DisplayChipKind::VirtualEnvironment => Some(self.virtual_environment_chip(app)),
DisplayChipKind::NodeVersion { popup, popup_open } => {
Some(self.node_version_chip(popup, *popup_open, app))
}
DisplayChipKind::CondaEnvironment => Some(self.conda_environment_chip(app)),
DisplayChipKind::AgentPlanAndTodoList { plan_and_todo_list } => {
Some(ChildView::new(plan_and_todo_list).finish())
}
DisplayChipKind::GitBranch { menu_open, menu } => {
Some(self.git_branch_chip(*menu_open, menu, app))
}
DisplayChipKind::GithubPullRequest => Some(self.github_pull_request_chip(app)),
DisplayChipKind::GitDiffStats { line_changes_info } => {
self.git_diff_stats_chip(line_changes_info, app)
}
_ => {
let mut text = Text::new_inline(String::new(), font_family, font_size)
.with_line_height_ratio(appearance.line_height_ratio());
// This is temporary, until we design more generic chip formatting.
// In sync with `ContextChipKind::display_value`.
render_text_from_kind(
&mut text,
self.chip_kind.clone(),
self.text.clone(),
self.is_in_agent_view,
appearance,
);
Some(chip_container(text.finish(), None, appearance).finish())
}
}
}
}
impl Entity for DisplayChip {
type Event = PromptDisplayChipEvent;
}
impl View for DisplayChip {
fn ui_name() -> &'static str {
"DisplayChip"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if let Some(chip) = self.render_chip(app) {
if self.is_in_agent_view {
chip
} else {
Container::new(chip)
.with_margin_right(CHIP_MARGIN_RIGHT)
.finish()
}
} else {
Empty::new().finish()
}
}
}
pub enum PromptDisplayChipEvent {
OpenFile(String),
OpenTextFileInCodeEditor(String),
ToggleMenu {
open: bool,
},
OpenCodeReview,
OpenConversationHistory,
OpenCommandPaletteFiles,
TryExecuteCommand(String),
RunAgentQuery(String),
OpenAIDocument {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
}
impl TypedActionView for DisplayChip {
type Action = DisplayChipAction;
fn handle_action(&mut self, action: &DisplayChipAction, ctx: &mut ViewContext<Self>) {
match action {
DisplayChipAction::CloseMenu => match &mut self.display_chip_kind {
DisplayChipKind::GitBranch { menu_open, menu } => {
*menu_open = false;
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
ctx.notify();
}
DisplayChipKind::WorkingDirectory {
menu_open, menu, ..
} => {
*menu_open = false;
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
ctx.notify();
}
DisplayChipKind::Ssh
| DisplayChipKind::Subshell
| DisplayChipKind::VirtualEnvironment
| DisplayChipKind::CondaEnvironment
| DisplayChipKind::AgentPlanAndTodoList { .. }
| DisplayChipKind::Text
| DisplayChipKind::GithubPullRequest
| DisplayChipKind::GitDiffStats { .. } => {}
DisplayChipKind::NodeVersion { popup_open, .. } => {
*popup_open = false;
ctx.notify();
}
},
DisplayChipAction::ToggleMenu => {
// All ToggleMenu consumers (WorkingDirectory, GitBranch, NodeVersion)
// route through shell commands (cd, git checkout, nvm use) that don't
// work in CLI agent context, so we suppress all of them.
if self.is_cli_agent_session_active(ctx) {
return;
}
match &mut self.display_chip_kind {
DisplayChipKind::GitBranch { menu, menu_open } => {
*menu_open = !*menu_open;
let is_menu_open = *menu_open;
if is_menu_open {
ctx.focus(menu);
} else {
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
}
ctx.emit(PromptDisplayChipEvent::ToggleMenu { open: is_menu_open });
if is_menu_open {
let is_udi_enabled = InputSettings::as_ref(ctx)
.is_universal_developer_input_enabled(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::ContextChipInteracted {
chip_type: "git_branch".to_string(),
action: "opened".to_string(),
is_udi_enabled,
},
ctx
);
}
ctx.notify();
}
DisplayChipKind::WorkingDirectory {
menu,
menu_open,
directory_fetcher,
..
} => {
*menu_open = !*menu_open;
let is_menu_open = *menu_open;
if is_menu_open {
// Explicitly refetch directory contents when menu opens
directory_fetcher.update(ctx, |fetcher, ctx| {
fetcher.refetch_directory(ctx);
});
ctx.focus(menu);
} else {
menu.update(ctx, |menu, _| {
menu.reset_selected_index();
});
}
ctx.emit(PromptDisplayChipEvent::ToggleMenu { open: is_menu_open });
if is_menu_open {
let is_udi_enabled = InputSettings::as_ref(ctx)
.is_universal_developer_input_enabled(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::ContextChipInteracted {
chip_type: "working_directory".to_string(),
action: "opened".to_string(),
is_udi_enabled,
},
ctx
);
}
ctx.notify();
}
DisplayChipKind::NodeVersion { popup, popup_open } => {
*popup_open = !*popup_open;
let is_open = *popup_open;
if is_open {
popup.update(ctx, |popup_view, popup_ctx| {
popup_view.focus_content(popup_ctx);
});
} else {
ctx.focus_self();
}
ctx.emit(PromptDisplayChipEvent::ToggleMenu { open: is_open });
ctx.notify();
}
_ => {}
}
}
DisplayChipAction::ToggleCodeReview => {
ctx.emit(PromptDisplayChipEvent::OpenCodeReview);
ctx.notify();
}
DisplayChipAction::OpenBranchSelector => {
// Delegate to the existing ToggleMenu action for branch selector
self.handle_action(&DisplayChipAction::ToggleMenu, ctx);
}
DisplayChipAction::OpenGithubPullRequest(url) => {
ctx.open_url(url);
}
}
}
}
/// Button theme for hint buttons in the classic input prompt.
pub struct ClassicPromptChipHintButton;
impl ActionButtonTheme for ClassicPromptChipHintButton {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
NakedTheme.background(hovered, appearance)
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
// For consistency, always choose the text color based on the input background.
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid()
}
}
/// Button theme for hint buttons in the UDI prompt chips.
pub struct UdiPromptChipHintButton;
impl ActionButtonTheme for UdiPromptChipHintButton {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
NakedTheme.background(hovered, appearance)
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
// For consistency, always choose the text color based on the input background.
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into_solid()
}
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
Some(internal_colors::neutral_3(appearance.theme()))
}
}
pub struct EnterAgentViewButton;
impl ActionButtonTheme for EnterAgentViewButton {
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
Some(if hovered {
internal_colors::fg_overlay_2(appearance.theme())
} else {
internal_colors::fg_overlay_1(appearance.theme())
})
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
appearance: &Appearance,
) -> ColorU {
appearance
.theme()
.main_text_color(appearance.theme().background())
.into_solid()
}
fn border_gradient(&self, appearance: &Appearance) -> Option<(Vector2F, Vector2F, Gradient)> {
Some((
vec2f(0.0, 0.0),
vec2f(3.0, 3.0),
Gradient {
start: appearance.theme().ansi_fg_magenta(),
end: appearance.theme().ansi_fg_yellow(),
},
))
}
fn should_opt_out_of_contrast_adjustment(&self) -> bool {
true
}
}
fn format_change_directory_command(dir_name: &str) -> String {
format!("cd '{}'", dir_name.replace("'", "'\\'''"))
}
pub fn format_git_branch_command(branch_name: &str) -> String {
format!("git checkout {branch_name}")
}
pub(crate) fn chip_container(
content: Box<dyn Element>,
border_override: Option<Border>,
appearance: &Appearance,
) -> Container {
let theme = appearance.theme();
let border = border_override.unwrap_or(
Border::all(CHIP_BORDER_WIDTH).with_border_color(internal_colors::neutral_3(theme)),
);
// Solid surface fill keeps the chip readable even when its parent isn't
// `theme.background()` (for example, over an alt-screen CLI agent).
Container::new(content)
.with_background(theme.surface_1())
.with_border(border)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CHIP_CORNER_RADIUS)))
.with_vertical_padding(spacing::UDI_CHIP_VERTICAL_PADDING)
.with_horizontal_padding(spacing::UDI_CHIP_HORIZONTAL_PADDING)
}
pub(crate) fn render_udi_chip(config: UdiChipConfig, appearance: &Appearance) -> Box<dyn Element> {
let font_size = udi_font_size(appearance);
let icon_size = font_size;
let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(icon) = config.icon {
content.add_child(
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(config.color)).finish())
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.with_margin_right(spacing::UDI_CHIP_ICON_GAP)
.finish(),
);
}
let display_text = if config.truncate_text {
truncate_from_beginning(&config.text, UDI_CHIP_MAX_NUM_CHARACTERS)
} else {
config.text.clone()
};
let font_family = if config.is_in_agent_view || !FeatureFlag::AgentView.is_enabled() {
appearance.ui_font_family()
} else {
appearance.monospace_font_family()
};
let mut rendered_text = Text::new_inline(display_text, font_family, font_size)
.with_color(Fill::Solid(config.color).into())
.with_line_height_ratio(appearance.line_height_ratio());
if !config.is_in_agent_view {
rendered_text = rendered_text.with_style(Properties::default().weight(Weight::Semibold))
}
content.add_child(rendered_text.finish());
if let Some(keystroke) = config.keystroke {
content.add_child(
appearance
.ui_builder()
.keyboard_shortcut(&keystroke)
.with_space_between_keys(4.)
.with_style(UiComponentStyles {
font_size: Some(appearance.monospace_font_size() - 4.),
width: Some(appearance.monospace_font_size()),
height: Some(appearance.monospace_font_size()),
padding: Some(Coords::default()),
margin: Some(Coords::default().left(4.)),
..Default::default()
})
.with_line_height_ratio(1.0)
.build()
.finish(),
);
}
let mut container = chip_container(content.finish(), config.border_override, appearance);
if config.hovered {
container = container.with_background(appearance.theme().surface_2());
}
container.finish()
}
pub fn udi_font_size(appearance: &Appearance) -> f32 {
appearance.monospace_font_size() - 1.
}
pub fn udi_icon_size(appearance: &Appearance, app: &AppContext) -> f32 {
app.font_cache().line_height(
appearance.monospace_font_size(),
DEFAULT_UI_LINE_HEIGHT_RATIO / 1.4,
)
}
#[cfg(test)]
#[path = "display_chip_test.rs"]
mod tests;