Files
galaxy/app/src/ai/blocklist/block/view_impl.rs
T
Ryan WardandClaude Opus 4.6 6f54e2cb30 v1.4.0: Auto-compact streaming, Bedrock summarization support, subagent orchestration, and Galaxy rebrand continuation
Major features:
- Auto-compact: triggers conversation summarization when context window >= 85%,
  compacts Bedrock message history to a summary pair, and tracks live context tokens
- Bedrock summarization: plumbs `is_summarization` flag through translator/client/response
  pipeline, handles SummarizeConversation input type, and marks `summarized` in metadata
- Session restore: rebuilds bedrock_message_history from persisted task messages via
  newly-public `convert_proto_message`, preventing empty history on reconnect
- Subagent orchestration: adds SubagentQuestion/Answer/CompletionSummary event types,
  parent-child question routing with depth limits, retry counting, and drain methods
- Summarization UI: inline SummarizationView in AI blocks with progress/finished states

Refactors:
- Rename WarpTheme → GalaxyTheme across ~100 files (rebrand continuation)
- Rename warp_home_config_dir → galaxy_home_config_dir and related path functions
- Predefined rules: replace "System Defined Rule #N" with descriptive names
  (e.g. "Correctness Over Speed", "Never Guess") and add lookup helpers
- Usage view: replace cumulative input/output token display with live context tokens,
  cache hit rate calculation, and separate cache read/write stats
- Telemetry: remove verbose doc comments, simplify trait definitions
- Facts view: simplify delete permission check (always allow local deletion)
- Remove warp_managed_paths_watcher.rs (dead code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 11:59:37 -05:00

1351 lines
58 KiB
Rust

//! This module contains the [`View`] trait implementation for [`AIBlock`]
//!
//! Helper functions for rendering different AIBlock components are exported by the header, query,
//! and output submodules, where the intended layout is:
//! ```text
//! —————————————————————————————————
//! | 1 block attached | <—————— header
//! | |
//! | <Avatar> What went wrong? | <—————— query
//! | |
//! | * The error in your Rust | <——
//! | project indicates a syntax | |
//! | issue. I will now try to run | |
//! | the following fix: | |——— output
//! | —————————————————————— | |
//! | | cargo fix | | |
//! | —————————————————————— | <——
//! —————————————————————————————————
//! ```
pub(super) mod common;
pub use common::FindContext;
mod comments;
mod header;
mod imported_comments;
mod input;
mod orchestration;
pub mod output;
pub mod query;
mod todos;
use common::get_highlight_ranges_for_find_matches;
use galaxy_core::features::FeatureFlag;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxyui::elements::{
Align, ConstrainedBox, CornerRadius, CrossAxisAlignment, Empty, Expanded, FormattedTextElement,
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, SavePosition,
SelectableArea,
};
use galaxyui::{
elements::{Border, Container, Flex, ParentElement},
AppContext, Element, SingletonEntity,
};
use galaxyui::{View, ViewContext};
use pathfinder_color::ColorU;
use settings::Setting as _;
use std::collections::{HashMap, HashSet};
use crate::ai::agent::AIAgentCitation;
use crate::ai::agent::AIAgentInput;
use crate::ai::blocklist::block::view_impl::header::{
render_overflow_menu_button, OVERFLOW_BUTTON_SIZE,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::model::AIBlockModelHelper;
use crate::appearance::Appearance;
use crate::settings::{AISettings, InputModeSettings, InputSettings};
use crate::terminal::model::blocks::{BlockHeightItem, RemovableBlocklistItem, RichContentItem};
use crate::terminal::model::rich_content::RichContentType;
use crate::terminal::TerminalView;
use crate::util::truncation::truncate_from_end;
use super::secret_redaction::SecretRedactionState;
use super::{
attachment_names, AIBlock, AIBlockAction, DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT,
HAS_PENDING_ACTION, RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID,
};
use super::TextLocation;
use crate::ai::blocklist::block::view_impl::comments::address_comment_chips;
use crate::ai::blocklist::block::{DetectedLinksState, RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID};
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::settings_view::SettingsSection;
use crate::terminal::block_list_element::BlockListMenuSource;
use crate::terminal::grid_renderer::URL_COLOR;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::TerminalAction;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::util::link_detection::DetectedLinkType;
use crate::workspace::WorkspaceAction;
use galaxy_core::ui::color::contrast::{
foreground_color_with_minimum_contrast, MinimumAllowedContrast,
};
use galaxy_core::ui::color::Rgb;
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxyui::elements::{Highlight, HighlightedRange, Text};
use galaxyui::fonts::Properties;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::components::UiComponent;
use itertools::Itertools;
/// Helper function to create gray strikethrough highlight for secrets
fn create_secret_gray_highlight() -> Highlight {
Highlight::new().with_text_style(
TextStyle::new()
.with_foreground_color(galaxyui::color::ColorU::new(128, 128, 128, 255))
.with_show_strikethrough(true),
)
}
/// Apply slash command highlighting (bold and magenta color) to an existing highlight or create a new one.
fn add_slash_command_highlight(
appearance: &Appearance,
existing_highlight: Option<Highlight>,
) -> Highlight {
let theme = appearance.theme();
let base_magenta = theme.ansi_fg_magenta();
// Determine the background color to use for contrast checking
// If there's an existing highlight with a background color in its TextStyle (e.g., find matches),
// use that. Otherwise, use the theme's background.
let background_color = if let Some(existing) = &existing_highlight {
let current_style = existing.text_style();
current_style
.background_color
.unwrap_or_else(|| theme.background().into_solid())
} else {
theme.background().into_solid()
};
// Enforce minimum contrast against the background
let slash_command_foreground_color = foreground_color_with_minimum_contrast(
base_magenta,
Rgb::from(background_color),
MinimumAllowedContrast::Text,
);
if let Some(existing) = existing_highlight {
// Preserve existing text style and properties, but update foreground color and make bold
let current_style = existing.text_style();
let updated_style = (*current_style).with_foreground_color(slash_command_foreground_color);
let current_properties = existing.properties();
let mut bold_properties = current_properties;
bold_properties.weight = galaxyui::fonts::Weight::Bold;
Highlight::new()
.with_text_style(updated_style)
.with_properties(bold_properties)
} else {
// Create new highlight with default properties and bold weight
let default_properties = Properties {
weight: galaxyui::fonts::Weight::Bold,
..Default::default()
};
Highlight::new()
.with_foreground_color(slash_command_foreground_color)
.with_properties(default_properties)
}
}
/// In shared ambient sessions, the first prompt is already shown by
/// `InitialUserQuery` during live startup/streaming.
///
/// To avoid duplicate UI, we suppress the first AI block's header/query only while the viewer is
/// live (not replaying historical conversation events).
///
/// That first prompt is rendered in the ambient-agent query block UI, so this helper only gates
/// duplicate rendering in the AI block path when that optimistic block was actually inserted.
fn should_hide_first_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block: bool,
is_shared_ambient_agent_session: bool,
is_first_exchange: bool,
is_receiving_agent_conversation_replay: bool,
) -> bool {
FeatureFlag::CloudModeSetupV2.is_enabled()
&& has_inserted_cloud_mode_user_query_block
&& is_shared_ambient_agent_session
&& is_first_exchange
&& !is_receiving_agent_conversation_replay
}
/// Adds the appropriate highlighting for secrets and links to the given text element.
#[allow(clippy::too_many_arguments)]
fn add_highlights_to_text(
mut text_element: Text,
detected_links_state: &DetectedLinksState,
secret_redaction_state: &SecretRedactionState,
find_context: Option<FindContext>,
location: TextLocation,
is_selecting: bool,
hover_properties: Option<Properties>,
slash_command_prefix_len: Option<usize>,
app: &AppContext,
) -> Text {
let mut link_highlight = Highlight::new().with_text_style(
TextStyle::new()
.with_foreground_color(*URL_COLOR)
.with_underline_color(*URL_COLOR),
);
let secret_hover_click_highlight =
Highlight::new().with_text_style(TextStyle::new().with_foreground_color(*URL_COLOR));
let mut highlighted_ranges = vec![];
if let Some(open_secret_tooltip) = &secret_redaction_state.open_tooltip_location() {
if open_secret_tooltip.location == location {
text_element = text_element.with_saved_char_position(
open_secret_tooltip.secret_range.char_range.start,
RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID.to_owned(),
);
}
}
// Add gray + strikethrough styling for all detected secrets when in strikethrough mode
if let Some(detected_secrets) = secret_redaction_state.secrets_for_location(&location) {
if matches!(
get_secret_obfuscation_mode(app),
ObfuscateSecrets::Strikethrough
) {
for secret_range in detected_secrets.detected_secrets.keys() {
// Skip gray styling if this secret is currently hovered or has tooltip open
if !secret_redaction_state.is_hovered(&location, secret_range)
&& !secret_redaction_state.has_open_tooltip(&location, secret_range)
{
let highlight_indices = secret_range.char_range.clone().collect_vec();
if highlight_indices.is_empty() {
continue;
}
highlighted_ranges.push(HighlightedRange {
highlight: create_secret_gray_highlight(),
highlight_indices,
});
}
}
}
}
// If we have an open tooltip, that secret should be highlighted.
if let Some(open_secret_tooltip) = &secret_redaction_state.open_tooltip_location() {
if open_secret_tooltip.location == location {
let highlight_indices = open_secret_tooltip
.secret_range
.char_range
.clone()
.collect_vec();
if !highlight_indices.is_empty() {
highlighted_ranges.push(HighlightedRange {
highlight: secret_hover_click_highlight,
highlight_indices,
});
}
}
}
// Also highlight any currently hovered secret if it's different.
if let Some(currently_hovered_secret) = &secret_redaction_state.hovered_location() {
if currently_hovered_secret.location == location
&& secret_redaction_state.hovered_location()
!= secret_redaction_state.open_tooltip_location()
{
let highlight_indices = currently_hovered_secret
.secret_range
.char_range
.clone()
.collect_vec();
if !highlight_indices.is_empty() {
highlighted_ranges.push(HighlightedRange {
highlight: secret_hover_click_highlight,
highlight_indices,
});
}
}
}
if !is_selecting {
if let Some(properties) = hover_properties {
link_highlight = link_highlight.with_properties(properties);
}
// Link highlighting.
// If we have an open tooltip, that link should be highlighted.
if let Some(open_link_tooltip) = &detected_links_state.link_location_open_tooltip {
if open_link_tooltip.location == location {
let highlight_indices = open_link_tooltip.link_range.clone().collect_vec();
if !highlight_indices.is_empty() {
highlighted_ranges.push(HighlightedRange {
highlight: link_highlight,
highlight_indices,
});
}
text_element = text_element.with_saved_char_position(
open_link_tooltip.link_range.start,
RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID.to_owned(),
);
}
}
// Also highlight any currently hovered link if it's different.
if let Some(currently_hovered_link) = &detected_links_state.currently_hovered_link_location
{
if currently_hovered_link.location == location
&& detected_links_state.currently_hovered_link_location
!= detected_links_state.link_location_open_tooltip
{
let highlight_indices = currently_hovered_link.link_range.clone().collect_vec();
if !highlight_indices.is_empty() {
highlighted_ranges.push(HighlightedRange {
highlight: link_highlight,
highlight_indices,
});
}
}
}
if let Some(find_context) = find_context {
highlighted_ranges.extend(get_highlight_ranges_for_find_matches(
location,
find_context.state,
find_context.model,
));
}
}
// Merge overlapping or contiguous ranges
let mut merged_highlighted_ranges =
HighlightedRange::merge_overlapping_ranges(highlighted_ranges);
if let Some(slash_command_prefix_len) = slash_command_prefix_len {
let appearance = Appearance::as_ref(app);
// Build a set of all indices in the slash_command prefix that need highlighting
let mut unhandled_plan_indices: HashSet<usize> = (0..slash_command_prefix_len).collect();
let mut result = vec![];
// Process existing highlights, updating any that overlap with the slash command prefix.
for range in merged_highlighted_ranges {
let range_start = range.highlight_indices.first().cloned().unwrap_or(0);
let range_end = range
.highlight_indices
.last()
.cloned()
.unwrap_or(range_start);
if range_end < slash_command_prefix_len {
// Range is entirely within /plan prefix
// Update to use plan color + bold, and mark these indices as handled
for &idx in &range.highlight_indices {
unhandled_plan_indices.remove(&idx);
}
result.push(HighlightedRange {
highlight: add_slash_command_highlight(appearance, Some(range.highlight)),
highlight_indices: range.highlight_indices,
});
} else if range_start >= slash_command_prefix_len {
// Range is entirely after /plan prefix - keep as-is
result.push(range);
} else {
// Range spans the /plan boundary - split it
let mut prefix_part_indices = vec![];
let mut after_part_indices = vec![];
for idx in range.highlight_indices {
if idx < slash_command_prefix_len {
prefix_part_indices.push(idx);
unhandled_plan_indices.remove(&idx);
} else {
after_part_indices.push(idx);
}
}
// Add the styling to the part containing the slash command.
if !prefix_part_indices.is_empty() {
result.push(HighlightedRange {
highlight: add_slash_command_highlight(appearance, Some(range.highlight)),
highlight_indices: prefix_part_indices,
});
}
// Add the after part with original styling
if !after_part_indices.is_empty() {
result.push(HighlightedRange {
highlight: range.highlight,
highlight_indices: after_part_indices,
});
}
}
}
// Add highlights for any remaining /plan indices that didn't overlap with existing highlights
if !unhandled_plan_indices.is_empty() {
let mut sorted_indices: Vec<usize> = unhandled_plan_indices.into_iter().collect();
sorted_indices.sort_unstable();
// Group contiguous indices into ranges
let mut current_group = vec![sorted_indices[0]];
for &idx in &sorted_indices[1..] {
if idx == current_group.last().unwrap() + 1 {
current_group.push(idx);
} else {
// Finish current group
result.push(HighlightedRange {
highlight: add_slash_command_highlight(appearance, None),
highlight_indices: current_group,
});
current_group = vec![idx];
}
}
// Add the final group
result.push(HighlightedRange {
highlight: add_slash_command_highlight(appearance, None),
highlight_indices: current_group,
});
}
merged_highlighted_ranges = result;
}
// If there are no highlights, don't add any
if merged_highlighted_ranges.is_empty() {
return text_element;
}
// Sort highlight ranges to avoid any panics (sorted ranges are expected).
text_element = text_element.with_highlights(
merged_highlighted_ranges
.into_iter()
.sorted_by_key(|highlighted_range| highlighted_range.highlight_indices[0]),
);
text_element
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn add_highlights_to_rich_text(
mut formatted_text_element: FormattedTextElement,
detected_links_state: Option<&DetectedLinksState>,
secret_redaction_state: &SecretRedactionState,
find_context: Option<FindContext<'_>>,
location_index: usize,
line_count: usize,
theme: &GalaxyTheme,
is_selecting: bool,
is_action: bool,
app: &AppContext,
) -> FormattedTextElement {
let ansi_blue = theme.terminal_colors().normal.blue;
let blue_fg = theme.ansi_fg(ansi_blue);
let url_highlight = Highlight::new().with_foreground_color(ansi_blue.into());
let url_hover_click_highlight = Highlight::new().with_text_style(
TextStyle::new()
.with_foreground_color(blue_fg)
.with_underline_color(blue_fg),
);
let file_hover_click_highlight = Highlight::new().with_text_style(
TextStyle::new()
.with_foreground_color(ansi_blue.into())
.with_underline_color(ansi_blue.into()),
);
let secret_hover_click_highlight =
Highlight::new().with_text_style(TextStyle::new().with_foreground_color(ansi_blue.into()));
for i in 0..line_count {
let location = if is_action {
TextLocation::Action {
action_index: location_index,
line_index: i,
}
} else {
TextLocation::Output {
section_index: location_index,
line_index: i,
}
};
let mut style_ranges = vec![];
if let Some(detected_links_state) = detected_links_state {
// Add highlighting to url links.
if let Some(links) = detected_links_state
.detected_links_by_location
.get(&location)
{
style_ranges = links
.detected_links
.iter()
.filter_map(|(range, link)| {
// Stylings like [](https://example.com) would cause warp to panic. We need to filter that out.
if range.is_empty() {
return None;
}
let highlight_indices = range.clone().collect_vec();
if highlight_indices.is_empty() {
return None;
}
// If the current link is hovered or clicked, highlight it. We add the styles here to
// prevent duplicate styles for the same link.
let mut link_highlight_location = detected_links_state
.currently_hovered_link_location
.as_ref();
if link_highlight_location.is_none_or(|link| {
link.location != location || link.link_range != *range
}) {
link_highlight_location =
detected_links_state.link_location_open_tooltip.as_ref()
}
if is_selecting {
link_highlight_location = None;
}
if let Some(link_location) = link_highlight_location {
if link_location.location == location
&& link_location.link_range == *range
{
let hover_highlight =
if matches!(link.link, DetectedLinkType::Url(_)) {
url_hover_click_highlight
} else {
file_hover_click_highlight
};
return Some(HighlightedRange {
highlight_indices,
highlight: hover_highlight,
});
}
}
if matches!(link.link, DetectedLinkType::Url(_)) {
Some(HighlightedRange {
highlight_indices,
highlight: url_highlight,
})
} else {
None
}
})
.collect_vec();
}
if let Some(open_link_tooltip) = &detected_links_state.link_location_open_tooltip {
if open_link_tooltip.location == location {
formatted_text_element = formatted_text_element.with_saved_glyph_position(
open_link_tooltip.link_range.start,
i,
RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID.to_owned(),
);
}
}
}
if let Some(open_secret_tooltip) = &secret_redaction_state.open_tooltip_location() {
if open_secret_tooltip.location == location {
formatted_text_element = formatted_text_element.with_saved_glyph_position(
open_secret_tooltip.secret_range.char_range.start,
i,
RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID.to_owned(),
);
if !is_selecting {
let highlight_indices = open_secret_tooltip
.secret_range
.char_range
.clone()
.collect_vec();
if !highlight_indices.is_empty() {
style_ranges.push(HighlightedRange {
highlight_indices,
highlight: secret_hover_click_highlight,
});
}
}
}
}
// Also highlight any currently hovered secret if it's different.
if let Some(currently_hovered_secret) = &secret_redaction_state.hovered_location() {
if currently_hovered_secret.location == location
&& secret_redaction_state.hovered_location()
!= secret_redaction_state.open_tooltip_location()
{
let highlight_indices = currently_hovered_secret
.secret_range
.char_range
.clone()
.collect_vec();
if !highlight_indices.is_empty() {
style_ranges.push(HighlightedRange {
highlight_indices,
highlight: secret_hover_click_highlight,
});
}
}
}
// Add gray + strikethrough styling for all detected secrets in rich text
if matches!(
get_secret_obfuscation_mode(app),
ObfuscateSecrets::Strikethrough
) {
if let Some(detected_secrets) = secret_redaction_state.secrets_for_location(&location) {
for secret_range in detected_secrets.detected_secrets.keys() {
// Skip gray styling if this secret is currently hovered or has tooltip open
if !secret_redaction_state.is_hovered(&location, secret_range)
&& !secret_redaction_state.has_open_tooltip(&location, secret_range)
{
let highlight_indices = secret_range.char_range.clone().collect_vec();
if highlight_indices.is_empty() {
continue;
}
style_ranges.push(HighlightedRange {
highlight: create_secret_gray_highlight(),
highlight_indices,
});
}
}
}
}
if let Some(find_params) = find_context.as_ref() {
style_ranges.extend(get_highlight_ranges_for_find_matches(
location,
find_params.state,
find_params.model,
));
}
let merged_range = HighlightedRange::merge_overlapping_ranges(style_ranges);
let sorted_range = merged_range
.into_iter()
.sorted_by_key(|range| range.highlight_indices[0]);
formatted_text_element.add_styles(i, sorted_range);
}
formatted_text_element
}
/// Renders a row of citations.
///
/// TODO: All AIBlock footer-related rendering logic should probably be put into its own View.
/// This function is needed both above (i.e. `block.rs`) and below (i.e. `output.rs`), and as such
/// cannot reside in `output.rs` because we don't want to make `mod output` public.
pub fn render_citation_chips(
citations: &[AIAgentCitation],
citation_state_handles: &HashMap<AIAgentCitation, MouseStateHandle>,
font_size: f32,
padding: f32,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let renderable_citations = citations
.iter()
.filter_map(|citation| {
citation_state_handles
.get(citation)
.and_then(|mouse_handle| {
render_citation(citation, mouse_handle.clone(), font_size, padding, app)
})
})
.collect_vec();
if renderable_citations.is_empty() {
return None;
}
Some(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children(
renderable_citations
.into_iter()
.map(|citation| Container::new(citation).with_margin_right(8.).finish()),
)
.finish(),
)
}
/// Renders a single citations chip.
///
/// TODO: All AIBlock footer-related rendering logic should probably be put into its own View.
/// This function is needed both above (i.e. `block.rs`) and below (i.e. `output.rs`), and as such
/// cannot reside in `output.rs` because we don't want to make `mod output` public.
pub fn render_citation(
citation: &AIAgentCitation,
mouse_state_handle: MouseStateHandle,
font_size: f32,
padding: f32,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let (icon, name) = match citation {
AIAgentCitation::WarpDriveObject { uid } => {
let item = CloudModel::as_ref(app)
.get_by_uid(uid)?
.to_warp_drive_item(appearance)?;
(
item.icon(appearance, Some(theme.active_ui_text_color())),
item.display_name().unwrap_or(String::from("Untitled")),
)
}
AIAgentCitation::WarpDocumentation { .. } => {
let icon = Icon::Warp.to_galaxyui_icon(theme.foreground()).finish();
let name = String::from("Warp Docs");
(Some(icon), name)
}
AIAgentCitation::WebPage { url } => {
let icon = Icon::LinkExternal
.to_galaxyui_icon(theme.foreground())
.finish();
let name = url.clone();
(Some(icon), name)
}
};
// Shorten the name to 30 chars.
let shortened_name = truncate_from_end(&name, 30);
let mut row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(icon) = icon {
row.add_child(
Container::new(
ConstrainedBox::new(icon)
.with_width(font_size)
.with_height(font_size)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
row.add_child(
Text::new_inline(shortened_name, appearance.ui_font_family(), font_size)
.with_color(theme.active_ui_text_color().into())
.with_selectable(false)
.finish(),
);
let chip = Container::new(row.finish())
.with_uniform_padding(padding)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(blended_colors::neutral_3(theme))
.finish();
let citation_clone = citation.clone();
Some(
Hoverable::new(mouse_state_handle, |_| chip)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::OpenCitation(citation_clone.clone()));
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
}
/// TODO: All AIBlock footer-related rendering logic should probably be put into its own View.
/// This function is needed both above (i.e. `block.rs`) and below (i.e. `output.rs`), and as such
/// cannot reside in `output.rs` because we don't want to make `mod output` public.
pub fn render_autonomy_checkbox_setting_speedbump_footer(
description: &'static str,
checked: bool,
on_toggled_action: AIBlockAction,
checkbox_handle: MouseStateHandle,
settings_link_handle: MouseStateHandle,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_child(
Container::new(
appearance
.ui_builder()
.checkbox(checkbox_handle, None)
.check(checked)
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(on_toggled_action.clone()))
.finish(),
)
.with_margin_left(-4.)
.finish(),
)
.with_child(
Container::new(
Text::new(
description,
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish(),
)
.with_margin_left(4.)
.finish(),
)
.with_child(
Expanded::new(
1.,
Align::new(
appearance
.ui_builder()
.link(
"Manage AI Autonomy permissions".into(),
None,
Some(Box::new(move |ctx| {
ctx.dispatch_typed_action(
WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "Autonomy".to_string(),
section: Some(SettingsSection::WarpAgent),
},
);
})),
settings_link_handle,
)
.build()
.finish(),
)
.right()
.finish(),
)
.finish(),
)
.finish()
}
impl View for AIBlock {
fn ui_name() -> &'static str {
"AIBlock"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
log::info!("[bedrock-debug] AIBlock::render() called");
// When the AI block is hidden, we don't need to render anything.
if self.is_hidden(app) {
return ConstrainedBox::new(Empty::new().finish())
.with_height(0.)
.finish();
}
// When the backing conversation has been cleared (e.g., after logout/reset), skip rendering.
// This can happen right after the user logs out, when the window is still potentially rendering for a few frames
// but the history model has already been cleared. It's safe to just skip rendering in this case.
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(app).conversation(&self.client_ids.conversation_id)
else {
return ConstrainedBox::new(Empty::new().finish())
.with_height(0.)
.finish();
};
let addressed_comment_ids = conversation.addressed_comment_ids();
let mut contents = Flex::column();
let (is_shared_ambient_agent_session, is_receiving_agent_conversation_replay) = {
let terminal_model = self.terminal_model.lock();
(
terminal_model.is_shared_ambient_agent_session(),
terminal_model.is_receiving_agent_conversation_replay(),
)
};
let is_first_exchange = conversation
.first_exchange()
.is_some_and(|exchange| exchange.id == self.client_ids.client_exchange_id);
let has_inserted_cloud_mode_user_query_block = self
.ambient_agent_view_model
.as_ref(app)
.has_inserted_cloud_mode_user_query_block();
let should_hide_first_block_query_and_header = should_hide_first_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block,
is_shared_ambient_agent_session,
is_first_exchange,
is_receiving_agent_conversation_replay,
);
let input_props = input::Props {
comments: &self.comment_states,
addressed_comment_ids: &addressed_comment_ids,
};
let initial_conversation_query = self
.model
.conversation(app)
.and_then(|c| c.initial_user_query());
let query_and_index = self
.model
.inputs_to_render(app)
.iter()
.enumerate()
.find_map(|(input_index, input)| {
let element_below_user_query = input.element_below_user_query(input_props, app);
let user_query = input.display_user_query(initial_conversation_query.as_ref())?;
let query_prefix_highlight_len =
common::query_prefix_highlight_len(input, &user_query);
Some((
user_query,
input_index,
query_prefix_highlight_len,
element_below_user_query,
))
});
let query_and_index_is_some =
query_and_index.is_some() && !should_hide_first_block_query_and_header;
let attachment_name_list = if FeatureFlag::ImageAsContext.is_enabled() {
attachment_names(self.model.inputs_to_render(app))
} else {
vec![]
};
if !should_hide_first_block_query_and_header {
if let Some((
query_for_display,
input_index,
query_prefix_highlight_len,
elements_below_query,
)) = query_and_index
{
let mut did_render_header = false;
if let Some(header) = header::render(
header::Props {
attached_blocks_chip_mouse_state: &self
.state_handles
.attached_blocks_chip_state_handle,
overflow_menu_mouse_state: &self.state_handles.overflow_menu_handle,
rewind_button: &self.rewind_button,
num_attached_context_blocks: self.num_attached_context_blocks,
has_attached_context_selected_text: self.has_attached_context_selected_text,
directory_context: &self.directory_context,
view_id: &self.view_id,
exchange_id: &self.client_ids.client_exchange_id,
conversation_id: &self.client_ids.conversation_id,
is_selected_text_attached_as_context: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
is_restored: self.is_restored(),
},
app,
) {
// Only render the prompt "header" for blocks containing a user query (as opposed to a
// requested command result).
contents.add_child(header.with_content_item_spacing().finish());
did_render_header = true;
}
// Derive the display info for the participant who initiated this exchange.
// For non-shared sessions, this is just the current user.
// For shared sessions, this is the user who initiated the request.
let (avatar_display_name, profile_image_path, avatar_color) = self
.model
.response_initiator(app)
.and_then(|participant_id| {
app.view_with_id::<TerminalView>(self.window_id, self.terminal_view_id)
.and_then(|terminal_view| {
terminal_view.read(app, |view, app| {
view.shared_session_presence_manager().and_then(move |pm| {
pm.as_ref(app).get_participant(&participant_id).map(
|participant| {
// Get the display info from the participant
// who sent this query.
(
participant
.info
.profile_data
.display_name
.clone(),
participant.info.profile_data.photo_url.clone(),
Some(participant.color),
)
},
)
})
})
})
})
// Fallback to the current user's info if this is not a shared session
// or the participant is not found.
.unwrap_or((
self.user_display_name.clone(),
self.profile_image_path.clone(),
None,
));
if let Some(rendered_query) = query::maybe_render(
query::Props {
user_display_name: &avatar_display_name,
profile_image_path: profile_image_path.as_ref(),
avatar_color,
query_and_index: Some((&query_for_display, input_index)),
query_prefix_highlight_len,
detected_links_state: &self.detected_links_state,
secret_redaction_state: &self.secret_redaction_state,
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
is_ai_input_enabled: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
attachments: &attachment_name_list,
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
FindContext {
model: self.find_model.as_ref(app),
state: &self.find_state,
},
),
},
app,
) {
if did_render_header {
contents.add_child(rendered_query.with_content_item_spacing().finish());
} else {
// The query element is designed to be exactly icon_size() height.
let rendered_query_height = icon_size(app);
let margin_bottom = (CONTENT_ITEM_VERTICAL_MARGIN
- (OVERFLOW_BUTTON_SIZE - rendered_query_height).max(0.))
.max(0.);
contents.add_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Expanded::new(1., rendered_query).finish())
.with_child(render_overflow_menu_button(
self.state_handles.overflow_menu_handle.clone(),
self.view_id,
self.client_ids.client_exchange_id,
self.client_ids.conversation_id,
self.is_restored(),
app,
))
.finish()
.with_content_item_spacing()
.with_margin_bottom(margin_bottom)
.finish(),
);
}
}
if let Some(element) = elements_below_query {
contents.add_child(element.with_agent_output_item_spacing(app).finish());
}
}
}
let has_accepted_edits = self.has_accepted_file_edits_since_last_query(app);
let terminal_model = self.terminal_model.lock();
let shared_session_status = terminal_model.shared_session_status().clone();
let is_conversation_transcript_viewer = terminal_model.is_conversation_transcript_viewer();
drop(terminal_model);
contents.add_child(output::render(
output::Props {
model: self.model.as_ref(),
state_handles: &self.state_handles,
action_buttons: &self.action_buttons,
view_screenshot_buttons: &self.view_screenshot_buttons,
action_model: &self.action_model,
editor_views: &self.code_editor_views,
current_working_directory: self.current_working_directory.as_ref(),
shell_launch_data: self.shell_launch_data.as_ref(),
detected_links_state: &self.detected_links_state,
secret_redaction_state: &self.secret_redaction_state,
requested_commands: &self.requested_commands,
requested_mcp_tools: &self.requested_mcp_tools,
requested_edits: &self.requested_edits,
unit_test_suggestions: &self.unit_tests_suggestions,
todo_list_states: &self.todo_list_states,
collapsible_block_states: &self.collapsible_block_states,
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
is_ai_input_enabled: self
.context_model
.as_ref(app)
.pending_context_selected_text()
.is_some(),
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
FindContext {
model: self.find_model.as_ref(app),
state: &self.find_state,
},
),
is_references_section_open: self.is_references_section_open,
autonomy_setting_speedbump: &self.autonomy_setting_speedbump,
suggested_rules: &self.suggested_rules,
suggested_agent_mode_workflow: &self.suggested_agent_mode_workflow,
manage_rules_button: &self.manage_rules_button,
keyboard_navigable_buttons: self.keyboard_navigable_buttons.as_ref(),
response_rating: &self.response_rating,
request_refunded_count: self.request_refunded_count,
search_codebase_view: &self.search_codebase_view,
web_search_views: &self.web_search_views,
web_fetch_views: &self.web_fetch_views,
summarization_views: &self.summarization_views,
review_changes_button: &self.review_changes_button,
open_all_comments_button: &self.open_all_comments_button,
dismiss_suggestion_button: &self.dismiss_suggestion_button,
disable_rule_suggestions_button: &self.disable_rule_suggestions_button,
has_accepted_edits,
current_todo_list: self.current_todo_list(app),
finish_reason: self.finish_reason.as_ref(),
is_usage_footer_expanded: self.is_usage_footer_expanded,
shared_session_status: &shared_session_status,
terminal_view_id: self.terminal_view_id,
is_conversation_transcript_viewer,
aws_bedrock_credentials_error_view: self
.aws_bedrock_credentials_error_view
.as_ref(),
imported_comments: &self.imported_comments,
#[cfg(feature = "local_fs")]
resolved_code_block_paths: &self.resolved_code_block_paths,
#[cfg(feature = "local_fs")]
resolved_blocklist_image_sources: &self.resolved_blocklist_image_sources,
thinking_display_mode: AISettings::as_ref(app).thinking_display_mode,
conversation_has_imported_comments: self
.model
.is_latest_non_passive_exchange_in_root_task(app)
&& self.has_imported_comments_in_current_thread(app),
ask_user_question_view: self.ask_user_question_view.as_ref(),
},
app,
));
let should_use_transparent_overlay = InputSettings::as_ref(app)
.is_universal_developer_input_enabled(app)
|| FeatureFlag::AgentView.is_enabled();
let theme = Appearance::as_ref(app).theme();
// Even though forked blocks are technically "restored", this is an implementation detail
// and should not be exposed to the user. Only truly restored blocks (i.e. blocks from a closed pane or session)
// should have the restored theme applied.
let background_color = if self.model.is_restored()
&& !self.model.is_forked()
&& !FeatureFlag::AgentView.is_enabled()
{
theme.restored_ai_blocks_overlay()
} else if should_use_transparent_overlay {
// Use a fully transparent background for universal developer input
Fill::Solid(ColorU::transparent_black())
} else {
theme.ai_blocks_overlay()
};
let mut content = Container::new(contents.finish()).with_background(background_color);
// Only render visual separation between this block and the previous block if this block
// is for a user query. Otherwise, this block is for an action result, which is tied to
// the output of the previous block, so we omit visual separation to make this block appear
// as if its part of the previous block.
//
// For example, consider a query that results in a requested command. We want the second
// block (where requested command output is the AI input) to appear part of the original
// query block.
let contains_user_query_and_is_not_pin_to_top = query_and_index_is_some
|| InputModeSettings::as_ref(app)
.input_mode
.value()
.is_inverted_blocklist();
let should_render_separator =
!FeatureFlag::AgentView.is_enabled() && contains_user_query_and_is_not_pin_to_top;
if should_render_separator {
content = content.with_border(Border::top(1.).with_border_fill(theme.outline()));
}
// Although `inputs_to_render` returns a vector, each AIBlock should only have one input.
// We're assuming that the first element of the vector corresponds to the correct input.
let renders_below_requested_command_view =
self.model.inputs_to_render(app).iter().any(|input| {
input.action_result().is_some_and(|result| {
result.result.is_requested_command() || result.result.is_call_mcp_tool()
})
});
// We don't always apply top padding to every block because we don't want a block's top
// padding to double up against its previous block's bottom padding. There are three exceptions:
// 1) If a separator is present, we'll naturally want padding on both sides.
// 2) If this block comes directly after a requested command, then the previous block will be
// a collapsible regular command block. Regardless of whether this command block is shown
// or hidden, this block will need to use top padding to create space.
// 3) If the rendered element directly above this block is NOT an AI block,
// and this block is not a passive conversation, then it needs top padding to create visual separation.
let terminal_model = self.terminal_model.lock();
let is_previous_blocklist_item_ai_block = terminal_model
.block_list()
.get_previous_block_height_item(RemovableBlocklistItem::RichContent(self.view_id))
.is_some_and(|item| {
if let BlockHeightItem::RichContent(RichContentItem {
content_type: Some(content_type),
..
}) = item
{
content_type == &RichContentType::AIBlock
} else {
false
}
});
let should_add_top_padding = !should_hide_first_block_query_and_header
&& (contains_user_query_and_is_not_pin_to_top
|| renders_below_requested_command_view
|| (!is_previous_blocklist_item_ai_block && !self.is_passive_conversation(app)));
if should_add_top_padding {
content = content.with_padding_top(CONTENT_VERTICAL_PADDING);
}
let semantic_selection = SemanticSelection::as_ref(app);
let selected_text = self.selected_text.clone();
let view_id = self.view_id;
let mut selectable = SelectableArea::new(
self.state_handles.selection_handle.clone(),
move |selection_args, _, _| {
*selected_text.write() = selection_args.selection;
},
SavePosition::new(content.finish(), self.saved_position_id().as_str()).finish(),
)
.with_word_boundaries_policy(semantic_selection.word_boundary_policy())
.with_smart_select_fn(semantic_selection.smart_select_fn())
.on_selection_right_click(move |ctx, position| {
ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu(
BlockListMenuSource::RichContentTextRightClick {
rich_content_view_id: view_id,
position_in_rich_content: position,
},
))
})
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(AIBlockAction::SelectText);
});
if FeatureFlag::RectSelection.is_enabled() {
selectable = selectable.should_support_rect_select();
}
// TODO(Simon): Bottom padding should be 24px on the final block when the input isn't visible.
// It isn't sufficient to do an `is_streaming()` check because inline actions waiting for user
// review (i.e. "OK if I run this command?") are technically completed blocks.
selectable.finish()
}
fn on_focus(&mut self, focus_ctx: &galaxyui::FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus_subview_if_necessary(ctx);
}
}
fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context {
let mut context = Self::default_keymap_context();
if self
.action_model
.as_ref(app)
.get_pending_action(app)
.is_some_and(|action| self.requested_action_ids.contains(&action.id))
{
context.set.insert(HAS_PENDING_ACTION);
}
if self.has_pending_requested_edit(app) {
context.set.insert(DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT);
}
context
}
}
/// The horizontal padding applied to the AIBlock's content.
///
/// This should be applied to all content in the AIBlock, with the exception of the requested
/// command UX when it is in "expanded" state, when the requested command item expands to the full
/// terminal width as part of a UI that attempts to make the requested command UX in the
/// AIBlock appear visually connected to the actual shell block despite them existing in different
/// branches of the view hierarchy.
///
/// Each sub-component of the AI block (header, query, output) is responsible for implementing its
/// own padding and margin using these values.
pub(crate) const CONTENT_HORIZONTAL_PADDING: f32 = 20.;
/// The vertical padding applied to the AIBlock's content.
///
/// When there is an expanded requested command block, the padding from the bottom of the block is
/// removed; the UI attempts to make the requested command UX in the AIBlock appear visually
/// connected to the actual shell block despite them existing in different branches of the view
/// hierarchy.
///
/// Each sub-component of the AI block (header, query, output) is responsible for implementing its
/// own padding and margin using these values.
pub(crate) const CONTENT_VERTICAL_PADDING: f32 = 16.;
/// The space in between each "item" in the AI block, e.g. between header, query, and each output
/// "step".
///
/// Each sub-component of the AI block (header, query, output) is responsible for implementing its
/// own padding and margin using these values.
pub(crate) const CONTENT_ITEM_VERTICAL_MARGIN: f32 = 16.;
pub(crate) trait WithContentItemSpacing {
/// Returns a [`Container`] with standard margin and padding values applied to be rendered as a
/// "content item" in an AI block.
///
/// The returned element is fit to be directly rendered within the AI block as a direct child
/// of the top-level container.
fn with_content_item_spacing(self) -> Container;
/// Returns a [`Container`] "content item" spacing with additional left margin to be specifically
/// applied to agent output items, intended to vertically align the left margin of agent output
/// items (text, reasoning, actions) with the user query.
fn with_agent_output_item_spacing(self, app: &AppContext) -> Container;
}
impl WithContentItemSpacing for Box<dyn Element> {
fn with_content_item_spacing(self) -> Container {
Container::new(self)
.with_horizontal_margin(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(CONTENT_ITEM_VERTICAL_MARGIN)
}
fn with_agent_output_item_spacing(self, app: &AppContext) -> Container {
let left_margin = CONTENT_HORIZONTAL_PADDING + icon_size(app) + 16.;
Container::new(self)
.with_margin_left(left_margin)
.with_margin_right(CONTENT_HORIZONTAL_PADDING)
.with_margin_bottom(CONTENT_ITEM_VERTICAL_MARGIN)
}
}
impl AIAgentInput {
/// Returns whether this [`AIAgentInput`] type should render a custom element below the user
/// query.
fn element_below_user_query(
&self,
props: input::Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
match self {
AIAgentInput::CodeReview {
review_comments, ..
} => Some(address_comment_chips(
&review_comments.review_comments(),
props,
app,
)),
AIAgentInput::UserQuery { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. }
| AIAgentInput::CreateEnvironment { .. }
| AIAgentInput::TriggerPassiveSuggestion { .. }
| AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. }
| AIAgentInput::FetchReviewComments { .. }
| AIAgentInput::SummarizeConversation { .. }
| AIAgentInput::InvokeSkill { .. }
| AIAgentInput::StartFromAmbientRunPrompt { .. }
| AIAgentInput::ActionResult { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. } => None,
}
}
}