first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+5 -3
View File
@@ -1,8 +1,10 @@
use crate::terminal::model::session::Session;
use galaxy_completer::parsers::simple::all_parsed_commands;
use smol_str::SmolStr;
use std::sync::Arc;
use smol_str::SmolStr;
use galaxy_completer::parsers::simple::all_parsed_commands;
use crate::terminal::model::session::Session;
/// Contains information about an aliased command.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AliasedCommand {
+4 -4
View File
@@ -1,10 +1,10 @@
use std::collections::HashMap;
use smol_str::SmolStr;
use super::*;
use crate::terminal::model::session::{
command_executor::testing::TestCommandExecutor, SessionInfo,
};
use std::collections::HashMap;
use crate::terminal::model::session::command_executor::testing::TestCommandExecutor;
use crate::terminal::model::session::SessionInfo;
#[test]
fn test_is_expandable_alias_when_expandable() {
@@ -1,3 +1,28 @@
use std::ops::{Deref as _, Range};
use std::sync::Arc;
use num_traits::Float as _;
use parking_lot::FairMutex;
use pathfinder_geometry::vector::vec2f;
use vec1::Vec1;
use galaxy_core::features::FeatureFlag;
use warp_util::user_input::UserInput;
use warpui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
use warpui::elements::{Axis, Point as UiPoint, ScrollData, ScrollableElement};
use warpui::event::{DispatchedEvent, InBoundsExt, KeyState, ModifiersState};
use warpui::fonts::Properties;
use warpui::geometry::rect::RectF;
use warpui::geometry::vector::Vector2F;
use warpui::platform::keyboard::KeyCode;
use warpui::text::SelectionType;
use warpui::units::{IntoLines, IntoPixels, Lines, Pixels};
use warpui::{
end_trace, record_trace_event, start_trace, AfterLayoutContext, AppContext, ClipBounds,
Element, EntityId, Event, EventContext, LayoutContext, ModelHandle, PaintContext,
SizeConstraint,
};
use super::{should_intercept_mouse, should_intercept_scroll};
use crate::appearance::Appearance;
use crate::pane_group::SplitPaneState;
use crate::settings::EnforceMinimumContrast;
@@ -22,34 +47,7 @@ use crate::terminal::shared_session::presence_manager::{
use crate::terminal::view::{
ActiveSessionState, TerminalAction, TerminalEditor, TerminalViewRenderContext,
};
use crate::terminal::{grid_renderer, SizeInfo};
use crate::terminal::{heights_approx_eq, TerminalModel};
use galaxy_core::features::FeatureFlag;
use galaxy_util::user_input::UserInput;
use galaxyui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
use galaxyui::event::{KeyState, ModifiersState};
use galaxyui::platform::keyboard::KeyCode;
use galaxyui::text::SelectionType;
use num_traits::Float as _;
use parking_lot::FairMutex;
use pathfinder_geometry::vector::vec2f;
use vec1::Vec1;
use super::{should_intercept_mouse, should_intercept_scroll};
use galaxyui::elements::{Axis, Fill, Point as UiPoint, ScrollData, ScrollableElement};
use galaxyui::fonts::Properties;
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::Vector2F;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::{
end_trace,
event::{DispatchedEvent, InBoundsExt},
record_trace_event, start_trace, AfterLayoutContext, AppContext, Element, Event, EventContext,
LayoutContext, PaintContext, SizeConstraint,
};
use galaxyui::{ClipBounds, EntityId, ModelHandle};
use std::ops::{Deref as _, Range};
use std::sync::Arc;
use crate::terminal::{grid_renderer, heights_approx_eq, SizeInfo, TerminalModel};
const CLI_SUBAGENT_HORIZONTAL_MARGIN: f32 = 8.;
const CLI_SUBAGENT_VERTICAL_MARGIN: f32 = 8.;
@@ -722,15 +720,6 @@ impl Element for AltScreenElement {
get_secret_obfuscation_mode(app).and(&grid.get_secret_obfuscation());
let mut sampler = model.alt_screen().bg_color_sampler.lock();
if let Some(bg_color) = sampler.most_common() {
if !bg_color.is_fully_transparent() {
if let Some(bounds) = self.bounds {
ctx.scene
.draw_rect_without_hit_recording(bounds)
.with_background(Fill::Solid(bg_color));
}
}
}
sampler.reset();
// Render grid cells. Since the alt screen has no scrollback we can always start at index 0.
+3 -3
View File
@@ -1,9 +1,9 @@
use galaxyui::{AppContext, SingletonEntity};
use settings::Setting as _;
use super::{
alt_screen_reporting::AltScreenReporting, model::grid::grid_handler::TermMode, TerminalModel,
};
use super::alt_screen_reporting::AltScreenReporting;
use super::model::grid::grid_handler::TermMode;
use super::TerminalModel;
pub mod alt_screen_element;
+2 -1
View File
@@ -1,4 +1,5 @@
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
use settings::macros::define_settings_group;
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
define_settings_group!(AltScreenReporting, settings: [
mouse_reporting_enabled: MouseReportingEnabled {
+1 -1
View File
@@ -3,7 +3,7 @@
use anyhow::Result;
use galaxyui::{Entity, SingletonEntity};
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(any(target_os = "linux", target_os = "freebsd"), path = "linux.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
// TODO(WASM): Replace this with a functional implementation for the web.
+7 -14
View File
@@ -12,15 +12,12 @@ use galaxyui::{Entity, SingletonEntity};
#[cfg(feature = "local_tty")]
use settings::Setting as _;
use super::session_settings::{NewSessionShell, StartupShell};
use super::shell::ShellType;
use super::ShellLaunchData;
#[cfg(feature = "local_tty")]
use crate::util::path::file_exists_and_is_executable;
use super::{
session_settings::{NewSessionShell, StartupShell},
shell::ShellType,
ShellLaunchData,
};
#[derive(Debug, PartialEq, Eq, Hash)]
struct LocalConfig {
command: String,
@@ -92,7 +89,7 @@ enum Config {
/// - Known Local: A shell that is known to be installed on the local filesystem, and can be run
/// by invoking an executable.
/// - Known WSL: A WSL distro that can be launched by invoking WSL with a specific distro flag
/// - Custom: A user-specified custom executable that cna be run locally.
/// - Custom: A user-specified custom executable that can be run locally.
/// - System Default: Uses the default shell for a given system.
///
/// All state is stored in an Arc so that it can be safely and easily copied. In general, unless you
@@ -399,7 +396,6 @@ impl TryFrom<&str> for AvailableShell {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
use crate::terminal::local_tty::shell::supported_shell_path_and_type;
let (path, shell_type) = supported_shell_path_and_type(value).ok_or(())?;
let command = path
.file_name()
@@ -605,8 +601,9 @@ impl AvailableShells {
value: AvailableShell,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
use super::session_settings::SessionSettings;
use galaxy_core::features::FeatureFlag;
use super::session_settings::SessionSettings;
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
if FeatureFlag::ShellSelector.is_enabled() {
settings
@@ -624,7 +621,6 @@ impl AvailableShells {
paths_to_search: &[PathBuf],
fallback_path: Option<&Path>,
) -> Vec<AvailableShell> {
use galaxy_core::features::FeatureFlag;
if !FeatureFlag::ShellSelector.is_enabled() {
return vec![
@@ -722,7 +718,6 @@ impl AvailableShells {
fn locate_msys2_executables() -> Vec<PathBuf> {
use std::env;
use galaxy_core::features::FeatureFlag;
let mut paths = Vec::new();
@@ -861,7 +856,6 @@ impl AvailableShells {
}
fn get_user_preferred_shell_setting(&self, ctx: &AppContext) -> NewSessionShell {
use super::session_settings::SessionSettings;
let new_session_shell_override = SessionSettings::as_ref(ctx)
.new_session_shell_override
@@ -878,7 +872,6 @@ impl AvailableShells {
}
fn get_user_preferred_shell_setting_fallback(&self, ctx: &AppContext) -> NewSessionShell {
use super::session_settings::SessionSettings;
let startup_shell = SessionSettings::as_ref(ctx)
.startup_shell_override
@@ -990,5 +983,5 @@ pub fn register(app: &mut impl galaxyui::AddSingletonModel) {
#[cfg(test)]
#[cfg(not(windows))]
#[path = "available_shells_test.rs"]
#[path = "available_shells_tests.rs"]
mod tests;
@@ -1,11 +1,13 @@
use super::*;
use crate::terminal::shell::ShellType;
use crate::test_util::{Stub, VirtualFS};
use galaxy_core::features::FeatureFlag;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use galaxy_core::features::FeatureFlag;
use super::*;
use crate::terminal::shell::ShellType;
use crate::test_util::{Stub, VirtualFS};
fn make_available_shells(shells: Vec<AvailableShell>) -> AvailableShells {
AvailableShells {
shells,
+24 -26
View File
@@ -1,35 +1,33 @@
use galaxy_editor::editor::NavigationKey;
use galaxyui::elements::{Align, Dash};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::FocusContext;
use galaxyui::{
accessibility::{AccessibilityContent, GalaxyA11yRole},
elements::{
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, DropShadow, Empty, Flex, Hoverable, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Shrinkable, Stack, Text,
},
presenter::ChildView,
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use regex_automata::hybrid::BuildError;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::{
appearance::Appearance,
editor::{
EditOrigin, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
SingleLineEditorOptions, TextOptions, ValidInputType,
},
send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
themes::theme::Fill,
ui_components::{blended_colors, icons::Icon},
use galaxy_editor::editor::NavigationKey;
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
use galaxyui::elements::{
Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dash, Dismiss, DropShadow, Empty, Flex, Hoverable, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Shrinkable,
Stack, Text,
};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use super::model::find::{FindConfig, RegexDFAs};
use crate::appearance::Appearance;
use crate::editor::{
EditOrigin, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
SingleLineEditorOptions, TextOptions, ValidInputType,
};
use crate::send_telemetry_from_ctx;
use crate::server::telemetry::TelemetryEvent;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::themes::theme::Fill;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
const FILTER_BLOCK_PLACEHOLDER_TEXT: &str = "Filter block output";
+265 -224
View File
@@ -1,58 +1,3 @@
use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState};
use crate::ai::blocklist::{ai_brand_color, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT};
use crate::ai_assistant::{AI_ASSISTANT_SVG_PATH, ASK_AI_ASSISTANT_TEXT};
use crate::appearance::Appearance;
use crate::drive::settings::WarpDriveSettings;
use crate::features::FeatureFlag;
use crate::pane_group::SplitPaneState;
use crate::settings::{AISettings, EnforceMinimumContrast, PrivacySettings, TerminalSpacing};
use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll};
use crate::terminal::block_list_viewport::AutoscrollBehavior;
use crate::terminal::input::inline_menu::InlineMenuPositioner;
use crate::terminal::model::block::{Block, BlockSection};
use crate::terminal::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, TotalIndex,
};
use crate::terminal::model::index::Point as IndexPoint;
use crate::terminal::model::selection::{SelectAction, SelectionPoint};
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::TerminalAction;
use crate::terminal::{grid_renderer, SizeInfo};
use crate::themes::theme::{Fill, GalaxyTheme};
use crate::ui_components::{self, icons as UIIcon};
use crate::util::color::Opacity;
use enum_iterator::Sequence;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::ui::builder::UiBuilder;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxy_util::user_input::UserInput;
use galaxyui::platform::Cursor;
use galaxyui::text::SelectionType;
use itertools::Itertools;
use parking_lot::FairMutex;
use vec1::Vec1;
use galaxyui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
use galaxyui::elements::{
Axis, Border, ChildAnchor, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Point, Radius, ScrollData, ScrollableElement, Stack, Text, ZIndex,
};
use galaxyui::event::{KeyState, ModifiersState};
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::platform::keyboard::KeyCode;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::{elements::Icon, ClipBounds};
use galaxyui::{
elements::SavePosition, event::DispatchedEvent, AfterLayoutContext, AppContext, Element, Event,
EventContext, LayoutContext, PaintContext, SizeConstraint,
};
use galaxyui::{EntityId, ModelHandle, SingletonEntity as _};
use pathfinder_color::ColorU;
use session_sharing_protocol::common::{ParticipantId, Selection};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::mem;
@@ -61,11 +6,41 @@ use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use enum_iterator::Sequence;
use itertools::Itertools;
use parking_lot::FairMutex;
use pathfinder_color::ColorU;
use session_sharing_protocol::common::{ParticipantId, Selection};
use vec1::Vec1;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::ui::builder::UiBuilder;
use galaxy_core::ui::theme::AnsiColorIdentifier;
use galaxy_util::user_input::UserInput;
use galaxyui::elements::new_scrollable::{NewScrollableElement, ScrollableAxis};
use galaxyui::elements::{
Axis, Border, ChildAnchor, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
Hoverable, Icon, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Point, Radius, SavePosition, ScrollData, ScrollableElement, Stack, Text,
ZIndex,
};
use galaxyui::event::{DispatchedEvent, KeyState, ModifiersState};
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::platform::keyboard::KeyCode;
use galaxyui::platform::Cursor;
use galaxyui::text::SelectionType;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::{
AfterLayoutContext, AppContext, ClipBounds, Element, EntityId, Event, EventContext,
LayoutContext, ModelHandle, PaintContext, SingletonEntity as _, SizeConstraint,
};
use super::block_list_viewport::{ClampingMode, InputMode, ScrollPosition, ViewportState};
use super::blockgrid_renderer::GridRenderParams;
use super::find::{BlockListFindRun, BlockListMatch, TerminalFindModel};
use super::find::{BlockFindRenderData, TerminalFindModel};
use super::grid_renderer::CellGlyphCache;
use super::meta_shortcuts::handle_keystroke_despite_composing;
use super::model::block::BlockId;
use super::model::blocks::{RichContentItem, SelectionRange};
@@ -84,15 +59,38 @@ use super::view::{
SharedSessionBanners, TerminalEditor, TerminalViewRenderContext, BLOCK_BANNER_HEIGHT,
};
use super::warpify::render::{draw_flag_pole, render_subshell_flag};
use super::TerminalModel;
use super::{heights_approx_eq, HEIGHT_FUDGE_FACTOR_LINES};
use super::{heights_approx_eq, TerminalModel, HEIGHT_FUDGE_FACTOR_LINES};
use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState};
use crate::ai::blocklist::{ai_brand_color, ATTACH_AS_AGENT_MODE_CONTEXT_TEXT};
use crate::ai_assistant::{AI_ASSISTANT_SVG_PATH, ASK_AI_ASSISTANT_TEXT};
use crate::appearance::Appearance;
use crate::drive::settings::WarpDriveSettings;
use crate::features::FeatureFlag;
use crate::pane_group::SplitPaneState;
use crate::settings::{
AISettings, DebugSettings, EnforceMinimumContrast, PrivacySettings, TerminalSpacing,
};
use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll};
use crate::terminal::block_list_viewport::AutoscrollBehavior;
use crate::terminal::blockgrid_renderer::BlockGridParams;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::warpify::SubshellSource;
use crate::terminal::input::inline_menu::InlineMenuPositioner;
use crate::terminal::model::block::{Block, BlockSection};
use crate::terminal::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, TotalIndex,
};
use crate::terminal::model::escape_sequences::{
maybe_kitty_keyboard_escape_sequence, KeystrokeWithDetails, ToEscapeSequence,
};
use crate::terminal::model::index::Point as IndexPoint;
use crate::terminal::model::selection::{SelectAction, SelectionPoint};
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::terminal::view::TerminalAction;
use crate::terminal::warpify::SubshellSource;
use crate::terminal::{grid_renderer, SizeInfo};
use crate::themes::theme::{Fill, WarpTheme};
use crate::ui_components::{self, icons as UIIcon};
use crate::util::color::Opacity;
/// The number of pixels at the bottom of padding where selection scrolling is performed.
const BOTTOM_VERTICAL_MARGIN: f32 = 10.0;
@@ -188,6 +186,8 @@ const SPACE_BETWEEN_SELECTED_BLOCK_AVATARS: f32 = 2.;
const CLI_SUBAGENT_HORIZONTAL_MARGIN: f32 = 8.;
const CLI_SUBAGENT_VERTICAL_MARGIN: f32 = 8.;
const CLI_SUBAGENT_MAX_WIDTH_RATIO: f32 = 0.75;
const CLI_SUBAGENT_MAX_HEIGHT_RATIO: f32 = 0.75;
pub type LabelBuilderFn = dyn Fn(
Vec<BlockIndex>,
@@ -214,7 +214,7 @@ pub type FilterBuilderFn = dyn Fn(
&AppContext,
) -> Vec<Option<Box<dyn Element>>>;
#[derive(Debug, PartialEq, Copy, Clone, Eq, PartialOrd, Sequence)]
#[derive(Debug, PartialEq, Copy, Clone, Eq, PartialOrd, Sequence, Hash)]
pub enum GridType {
Prompt,
Rprompt, // Right side prompt
@@ -1199,9 +1199,7 @@ impl BlockListElement {
self.ask_ai_assistant_button = Some(element);
}
if FeatureFlag::BlockToolbeltSaveAsWorkflow.is_enabled()
&& WarpDriveSettings::is_warp_drive_enabled(app)
{
if WarpDriveSettings::is_warp_drive_enabled(app) {
let icon = Container::new(
ConstrainedBox::new(
ui_components::icons::Icon::Save
@@ -1652,9 +1650,13 @@ impl BlockListElement {
return true;
}
if let Some(RichContentMetadata::AIBlock { .. }) =
self.rich_content_metadata.get(view_id)
{
if matches!(
self.rich_content_metadata.get(view_id),
Some(
RichContentMetadata::AIBlock(_)
| RichContentMetadata::PendingUserQuery { .. }
)
) {
should_redetermine_focus = false;
}
@@ -2463,7 +2465,7 @@ impl BlockListElement {
block: &Block,
grid_origin: &mut Vector2F,
element_origin: Vector2F,
block_list_find_run: Option<&BlockListFindRun>,
find_render_data: Option<BlockFindRenderData>,
highlighted_url: Option<&WithinBlock<Link>>,
link_tool_tip: Option<&WithinBlock<Link>>,
hovered_secret: Option<SecretHandle>,
@@ -2510,130 +2512,134 @@ impl BlockListElement {
Self::draw_border_between_blocks(border_origin, block_grid_params, ctx);
}
let prompt_height_offset = cell_size_height * block.padding_top().as_f64() as f32;
*grid_origin += vec2f(0., prompt_height_offset);
let prompt_origin = snackbar_header
.and_then(|header| header.header_rect())
.map_or(*grid_origin, |r| {
let y = r.origin().y() + prompt_height_offset + block_banner_height;
vec2f(grid_origin.x(), y)
});
let cursor_visible = block.is_mode_set(TermMode::SHOW_CURSOR);
// Draw prompt
if let Some(label_element) = label_element {
label_element.paint(prompt_origin, ctx, app);
} else {
let size_info = &block_grid_params.grid_render_params.size_info;
if block.should_display_rprompt(size_info) {
let rprompt_origin = prompt_origin + block.rprompt_render_offset(size_info);
block.rprompt_grid().draw(
rprompt_origin,
element_origin,
glyphs,
COMMAND_ALPHA,
None,
None,
hovered_secret,
None::<std::iter::Empty<&RangeInclusive<IndexPoint>>>,
None,
Properties::default(),
block_grid_params,
None,
image_metadata,
let command_origin = if !block.should_hide_command_grid() {
let prompt_height_offset = cell_size_height * block.padding_top().as_f64() as f32;
*grid_origin += vec2f(0., prompt_height_offset);
let prompt_origin = snackbar_header
.and_then(|header| header.header_rect())
.map_or(*grid_origin, |r| {
let y = r.origin().y() + prompt_height_offset + block_banner_height;
vec2f(grid_origin.x(), y)
});
// Draw prompt
if let Some(label_element) = label_element {
label_element.paint(prompt_origin, ctx, app);
} else {
let size_info = &block_grid_params.grid_render_params.size_info;
if block.should_display_rprompt(size_info) {
let rprompt_origin = prompt_origin + block.rprompt_render_offset(size_info);
block.rprompt_grid().draw(
rprompt_origin,
element_origin,
glyphs,
COMMAND_ALPHA,
None,
None,
hovered_secret,
None::<std::iter::Empty<&RangeInclusive<IndexPoint>>>,
None,
Properties::default(),
block_grid_params,
None,
image_metadata,
ctx,
app,
);
}
}
// If Warp prompt (non-PS1) is being used, the command is drawn below the prompt,
// hence we account for the prompt's vertical offset.
let prompt_vertical_offset_px = if !block.honor_ps1() {
cell_size_height
* (block.command_padding_top() + block.prompt_height()).as_f64() as f32
} else {
// Otherwise, the prompt/command are drawn together, in a single grid. Hence, we haven't
// drawn the prompt above and we do not account for the offset.
0.0
};
*grid_origin += vec2f(0.0, prompt_vertical_offset_px);
// Determine command_origin based on snackbar_header.
let command_origin = if snackbar_header.is_some() {
prompt_origin + vec2f(0.0, prompt_vertical_offset_px)
} else {
*grid_origin
};
// Update grid_origin and draw command.
let command_grid_properties = Properties::default();
let command_focused_range =
find_render_data
.as_ref()
.and_then(|data: &BlockFindRenderData<'_>| {
data.focused_range_for_grid(GridType::PromptAndCommand)
});
block.prompt_and_command_grid().draw(
command_origin,
element_origin,
glyphs,
COMMAND_ALPHA,
highlighted_url
.filter(|url| url.is_in_command_content() && url.block_index == block_index)
.map(|url| &url.inner),
link_tool_tip
.filter(|url| url.is_in_command_content() && url.block_index == block_index)
.map(|url| &url.inner),
hovered_secret,
find_render_data
.as_ref()
.and_then(|data: &BlockFindRenderData<'_>| data.command_grid_matches()),
command_focused_range.as_ref(),
command_grid_properties,
block_grid_params,
cursor_visible.then(|| block.prompt_and_command_grid().cursor_style().shape),
image_metadata,
ctx,
app,
);
// Only render the cursor in the command grid if the command grid is active and if it's
// long running. This is to avoid jitter where a cursor just flickers while the pty is
// initializing.
if block.is_active_and_long_running()
&& block.is_command_grid_active()
// Check if the "hide cursor" escape sequence is present.
&& block.is_mode_set(TermMode::SHOW_CURSOR)
{
block.prompt_and_command_grid().draw_cursor(
command_origin,
&block_grid_params.grid_render_params,
ctx,
terminal_view_id,
None,
block_grid_params
.grid_render_params
.warp_theme
.cursor()
.into(),
app,
);
}
}
// If Warp prompt (non-PS1) is being used, the command is drawn below the prompt,
// hence we account for the prompt's vertical offset.
let prompt_vertical_offset_px = if !block.honor_ps1() {
cell_size_height * (block.command_padding_top() + block.prompt_height()).as_f64() as f32
} else {
// Otherwise, the prompt/command are drawn together, in a single grid. Hence, we haven't
// drawn the prompt above and we do not account for the offset.
0.0
};
// Update grid_origin & draw output
*grid_origin += vec2f(
0.,
cell_size_height
* (block.padding_middle() + block.prompt_and_command_grid().len().into_lines())
.as_f64() as f32,
);
*grid_origin += vec2f(0.0, prompt_vertical_offset_px);
// Determine command_origin based on snackbar_header.
let command_origin = if snackbar_header.is_some() {
prompt_origin + vec2f(0.0, prompt_vertical_offset_px)
command_origin
} else {
*grid_origin
};
// Update grid_origin and draw command.
let command_grid_properties = Properties::default();
block.prompt_and_command_grid().draw(
command_origin,
element_origin,
glyphs,
COMMAND_ALPHA,
highlighted_url
.filter(|url| url.is_in_command_content() && url.block_index == block_index)
.map(|url| &url.inner),
link_tool_tip
.filter(|url| url.is_in_command_content() && url.block_index == block_index)
.map(|url| &url.inner),
hovered_secret,
block_list_find_run
.map(|run| run.matches_for_block_grid(block_index, GridType::PromptAndCommand)),
block_list_find_run
.and_then(|run| run.focused_match())
.and_then(|focused_match| match focused_match {
BlockListMatch::CommandBlock(m)
if m.block_index == block_index
&& m.grid_type == GridType::PromptAndCommand =>
{
Some(&m.range)
}
_ => None,
}),
command_grid_properties,
block_grid_params,
cursor_visible.then(|| block.prompt_and_command_grid().cursor_style().shape),
image_metadata,
ctx,
app,
);
// Only render the cursor in the command grid if the command grid is active and if it's
// long running. This is to avoid jitter where a cursor just flickers while the pty is
// initializing.
if block.is_active_and_long_running()
&& block.is_command_grid_active()
// Check if the "hide cursor" escape sequence is present.
&& block.is_mode_set(TermMode::SHOW_CURSOR)
{
block.prompt_and_command_grid().draw_cursor(
command_origin,
&block_grid_params.grid_render_params,
ctx,
terminal_view_id,
None,
block_grid_params
.grid_render_params
.warp_theme
.cursor()
.into(),
app,
);
}
// Update grid_origin & draw output
*grid_origin += vec2f(
0.,
cell_size_height
* (block.padding_middle() + block.prompt_and_command_grid().len().into_lines())
.as_f64() as f32,
);
let block_middle_lines =
block.padding_middle() + block.prompt_and_command_number_of_rows().into_lines();
if let Some(header_rect) = snackbar_header.map(|h| h.header_rect()).flatten() {
@@ -2670,6 +2676,12 @@ impl BlockListElement {
let output_grid_properties =
Properties::default().weight(block_grid_params.grid_render_params.font_weight);
let output_focused_range =
find_render_data
.as_ref()
.and_then(|data: &BlockFindRenderData<'_>| {
data.focused_range_for_grid(GridType::Output)
});
block.output_grid().draw(
*grid_origin,
viewport_origin,
@@ -2682,19 +2694,11 @@ impl BlockListElement {
.filter(|url| !url.is_in_command_content() && url.block_index == block_index)
.map(|url| &url.inner),
hovered_secret,
// Render find matches in output grid
block_list_find_run
.map(|run| run.matches_for_block_grid(block_index, GridType::Output)),
block_list_find_run
.and_then(|run| run.focused_match())
.and_then(|focused_match| match focused_match {
BlockListMatch::CommandBlock(m)
if m.block_index == block_index && m.grid_type == GridType::Output =>
{
Some(&m.range)
}
_ => None,
}),
// Render find matches in output grid.
find_render_data
.as_ref()
.and_then(|data: &BlockFindRenderData<'_>| data.output_grid_matches()),
output_focused_range.as_ref(),
output_grid_properties,
block_grid_params,
cursor_visible.then(|| block.output_grid().cursor_style().shape),
@@ -3065,7 +3069,6 @@ impl BlockListElement {
state: &KeyState,
ctx: &mut EventContext,
) -> bool {
use crate::terminal::view::TerminalAction;
if let Some(voice_input_toggle_key_code) = self.voice_input_toggle_key_code {
if *key_code == voice_input_toggle_key_code {
@@ -3358,14 +3361,16 @@ impl Element for BlockListElement {
self.cli_subagent_views.get_mut(block.id())
{
let block_height = (height.as_f64() as f32) * cell_size.y();
let max_width = (constraint.max.x() * CLI_SUBAGENT_MAX_WIDTH_RATIO
- CLI_SUBAGENT_HORIZONTAL_MARGIN)
.max(0.);
let max_height = (block_height - CLI_SUBAGENT_VERTICAL_MARGIN * 2.)
.min(constraint.max.y() * CLI_SUBAGENT_MAX_HEIGHT_RATIO)
.max(0.);
cli_subagent_view.layout(
SizeConstraint {
min: vec2f(0., 0.),
max: vec2f(
constraint.max.x() * 0.4
- CLI_SUBAGENT_HORIZONTAL_MARGIN,
block_height - CLI_SUBAGENT_VERTICAL_MARGIN * 2.,
),
max: vec2f(max_width, max_height),
},
ctx,
app,
@@ -3402,7 +3407,7 @@ impl Element for BlockListElement {
});
visible_height_px += height_px;
// we want to show different text in the seperator if this is an indvidual conversation
// we want to show different text in the separator if this is an individual conversation
// restored from the command palette
let banner_intro_text = if is_historical_conversation_restoration {
"Conversation restored".to_string()
@@ -3990,7 +3995,13 @@ impl Element for BlockListElement {
self.find_model
.as_ref(app)
.is_find_bar_open()
.then(|| self.find_model.as_ref(app).block_list_find_run())
.then(|| {
self.find_model.as_ref(app).find_render_data_for_block(
*block_index,
Some(block.prompt_and_command_grid().grid_handler()),
Some(block.output_grid().grid_handler()),
)
})
.flatten(),
self.highlighted_url.as_ref(),
self.link_tool_tip.as_ref(),
@@ -4135,12 +4146,9 @@ impl Element for BlockListElement {
ask_ai_assistant_button.paint(ask_ai_assistant_button_origin, ctx, app);
}
if FeatureFlag::BlockToolbeltSaveAsWorkflow.is_enabled() {
if let Some(save_as_workflow_button) =
self.save_as_workflow_button.as_mut()
{
save_as_workflow_button.paint(bookmark_button_origin, ctx, app);
}
if let Some(save_as_workflow_button) = self.save_as_workflow_button.as_mut()
{
save_as_workflow_button.paint(bookmark_button_origin, ctx, app);
}
}
@@ -4149,15 +4157,6 @@ impl Element for BlockListElement {
filter_element.paint(filter_button_origin, ctx, app);
}
if !FeatureFlag::BlockToolbeltSaveAsWorkflow.is_enabled() {
// When a block is bookmarked, we want the bookmark icon to show even when the block is not hovered over.
if let Some(bookmark_element) = self.bookmark_elements.get_mut(block_index)
{
// Paint the bookmark icon to the left of the overflow button.
bookmark_element.paint(bookmark_button_origin, ctx, app);
}
}
// Paint the CLI subagent view on top of everything else for this block
let mut render_params = CLISubagentRenderParams {
block_id: block.id().clone(),
@@ -4299,7 +4298,11 @@ impl Element for BlockListElement {
}
}
draw_border_above_block = true;
// Don't draw a border below session headers (i.e. above the next block).
draw_border_above_block = !matches!(
self.rich_content_metadata.get(view_id),
Some(RichContentMetadata::HarnessSessionHeader)
);
grid_origin += vec2f(0., *height_px);
}
@@ -4383,9 +4386,47 @@ impl Element for BlockListElement {
app: &AppContext,
) -> bool {
let z_index = self.child_max_z_index.expect("Z-index should exist.");
let Some(event_at_z_index) = event.at_z_index(z_index, ctx) else {
// Only proceed if there's a relevant event at this z-index.
return false;
// During an active text selection, bypass the z-index coverage check for
// drag events. The input/footer area below the block list is painted at a
// higher z-index, so `at_z_index` would filter out drags that cross into
// that region — breaking selection auto-scroll when dragging downward.
// This matches the pattern used by SelectableArea, Draggable, Resizable,
// and both Scrollable variants, which all use `raw_event()` for drags.
let event_at_z_index = if self.is_terminal_selecting
&& matches!(
event.raw_event(),
Event::LeftMouseDragged { .. } | Event::LeftMouseUp { .. }
) {
event.raw_event()
} else {
let Some(e) = event.at_z_index(z_index, ctx) else {
// The event is behind an overlay. Still dispatch interactive
// events to rich content views so overlay children (e.g.
// ask-user-question speedbump dropdowns) can handle them.
if matches!(
event.raw_event(),
Event::ScrollWheel { .. }
| Event::LeftMouseDown { .. }
| Event::LeftMouseUp { .. }
| Event::LeftMouseDragged { .. }
| Event::MiddleMouseDown { .. }
| Event::RightMouseDown { .. }
| Event::BackMouseDown { .. }
| Event::ForwardMouseDown { .. }
) && self.pane_state.is_focused()
{
let mut handled = false;
for view_id in self.visible_rich_content_views() {
if let Some(rich_content) = self.rich_content_elements.get_mut(&view_id) {
handled |= rich_content.dispatch_event(event, ctx, app);
}
}
return handled;
}
return false;
};
e
};
let mut handled = false;
@@ -4534,7 +4575,7 @@ impl Element for BlockListElement {
is_first_mouse,
modifiers,
..
} => self.mouse_down(
} if !handled => self.mouse_down(
*position,
*click_count,
*is_first_mouse,
+14 -1
View File
@@ -1,4 +1,5 @@
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
use settings::macros::define_settings_group;
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
// Settings for controlling the behavior of the block list.
define_settings_group!(BlockListSettings, settings: [
@@ -20,6 +21,18 @@ define_settings_group!(BlockListSettings, settings: [
toml_path: "general.snackbar_enabled",
description: "Whether to show snackbar notifications.",
}
// When enabled, the input box retains focus when selecting a block in shell mode
// (useful for quickly attaching context). When disabled, selecting a block focuses
// the terminal so blocklist navigation with arrow keys continues to work.
preserve_input_focus_on_block_selection: PreserveInputFocusOnBlockSelection {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "general.preserve_input_focus_on_block_selection",
description: "Whether to preserve input box focus when selecting a block.",
}
show_block_dividers: ShowBlockDividers {
type: bool,
default: true,
+22 -27
View File
@@ -1,39 +1,34 @@
use std::{ops::Range, rc::Rc, sync::MutexGuard};
use std::ops::Range;
use std::rc::Rc;
use std::sync::MutexGuard;
use galaxy_core::features::FeatureFlag;
use galaxyui::{
elements::ClippedScrollStateHandle,
units::{IntoLines, IntoPixels, Lines, Pixels},
AppContext, ModelHandle,
};
use pathfinder_geometry::vector::Vector2F;
use serde::{Deserialize, Serialize};
use sum_tree::{Cursor, SeekBias};
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::ClippedScrollStateHandle;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::{AppContext, ModelHandle};
use crate::{
ai::blocklist::agent_view::AgentViewDisplayMode,
terminal::{input::inline_menu::InlineMenuPositioner, model::index::Point as IndexPoint},
use super::block_list_element::{
GridType, SnackbarHeader, SnackbarHeaderState, SnackbarPoint, VisibleItem,
};
use crate::{ai::blocklist::agent_view::AgentViewState, terminal::model::blocks::RichContentItem};
use super::model::block::{Block, BlockSection};
use super::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, SelectionRange,
TotalIndex,
};
use super::model::selection::SelectionPoint;
use super::model::terminal_model::{BlockIndex, BlockSortDirection, WithinBlock};
use super::view::BlockVisibilityMode;
use super::{
block_list_element::{
GridType, SnackbarHeader, SnackbarHeaderState, SnackbarPoint, VisibleItem,
},
height_in_range_approx, heights_approx_gt, heights_approx_gte, heights_approx_lt,
heights_approx_lte,
model::{
block::{Block, BlockSection},
blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint,
SelectionRange, TotalIndex,
},
selection::SelectionPoint,
terminal_model::{BlockIndex, BlockSortDirection, WithinBlock},
},
view::BlockVisibilityMode,
SizeInfo, HEIGHT_FUDGE_FACTOR_LINES,
heights_approx_lte, SizeInfo, HEIGHT_FUDGE_FACTOR_LINES,
};
use crate::ai::blocklist::agent_view::{AgentViewDisplayMode, AgentViewState};
use crate::terminal::input::inline_menu::InlineMenuPositioner;
use crate::terminal::model::blocks::RichContentItem;
use crate::terminal::model::index::Point as IndexPoint;
/// Wraps a scroll position for the purposes of centralizing update logic.
pub struct ScrollState {
+9 -9
View File
@@ -1,3 +1,12 @@
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxyui::elements::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use galaxyui::event::DispatchedEvent;
use galaxyui::geometry::rect::RectF;
use super::blockgrid_renderer::GridRenderParams;
use crate::appearance::Appearance;
use crate::settings::EnforceMinimumContrast;
use crate::terminal::blockgrid_renderer::BlockGridParams;
@@ -5,15 +14,6 @@ use crate::terminal::model::blockgrid::BlockGrid;
use crate::terminal::model::grid::Dimensions;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::{color, SizeInfo};
use galaxyui::elements::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
SizeConstraint,
};
use galaxyui::event::DispatchedEvent;
use galaxyui::geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use super::blockgrid_renderer::GridRenderParams;
pub struct BlockGridElement {
block_grid: BlockGrid,
+12 -13
View File
@@ -1,25 +1,24 @@
use crate::settings::EnforceMinimumContrast;
use crate::terminal::color;
use crate::terminal::grid_renderer::{render_cursor, render_grid, CellGlyphCache};
use crate::terminal::model::blockgrid::{BlockGrid, CursorDisplayPoint};
use crate::terminal::model::grid::grid_handler::Link;
use crate::terminal::model::index::Point;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::SizeInfo;
use crate::themes::theme::GalaxyTheme;
use std::collections::HashMap;
use std::ops::{Neg, RangeInclusive};
use pathfinder_color::ColorU;
use galaxyui::fonts::{FamilyId, Properties, Weight};
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::{AppContext, Element, EntityId, PaintContext};
use pathfinder_color::ColorU;
use std::collections::HashMap;
use std::ops::Neg;
use std::ops::RangeInclusive;
use super::model::ansi::{CursorShape, CursorStyle};
use super::model::grid::RespectDisplayedOutput;
use super::model::image_map::StoredImageMetadata;
use super::model::SecretHandle;
use crate::settings::EnforceMinimumContrast;
use crate::terminal::grid_renderer::{render_cursor, render_grid, CellGlyphCache};
use crate::terminal::model::blockgrid::{BlockGrid, CursorDisplayPoint};
use crate::terminal::model::grid::grid_handler::Link;
use crate::terminal::model::index::Point;
use crate::terminal::model::ObfuscateSecrets;
use crate::terminal::{color, SizeInfo};
use crate::themes::theme::WarpTheme;
pub struct GridRenderParams {
pub warp_theme: GalaxyTheme,
+63 -14
View File
@@ -4,20 +4,20 @@ use galaxyui::{AppContext, AssetProvider, SingletonEntity};
use itertools::Itertools;
use lazy_static::lazy_static;
use memo_map::MemoMap;
use crate::{
env_vars::EnvVar,
terminal::{session_settings::SessionSettings, shell::ShellType},
};
use rand::Rng;
use galaxy_core::session_id::SessionId;
#[cfg(feature = "local_fs")]
use super::{
model::session::{BootstrapSessionType, SessionInfo},
warpify::settings::{PIPENV_SUBSHELL_COMMAND_REGEX, POETRY_SUBSHELL_COMMAND_REGEX},
};
use crate::env_vars::{EnvVar, EnvVarExt};
use crate::terminal::session_settings::SessionSettings;
use crate::terminal::shell::ShellType;
lazy_static! {
/// A memoized cache of the fully-interpolated boostrap script for each
/// A memoized cache of the fully-interpolated bootstrap script for each
/// shell. We store the full version here as an optimization so that we
/// don't have to regenerate it every time we spawn a shell.
static ref BOOTSTRAP_CACHE: MemoMap<ShellType, Vec<u8>> = Default::default();
@@ -27,6 +27,18 @@ lazy_static! {
/// errors
const BYTE_ORDER_MARK: &str = "\u{FEFF}";
#[cfg(feature = "local_fs")]
pub fn is_container_subshell(session_info: &SessionInfo) -> bool {
session_info.subshell_info.as_ref().is_some_and(|info| {
let first_token = info
.spawning_command
.split_ascii_whitespace()
.next()
.unwrap_or("");
first_token == "docker" || first_token == "podman"
})
}
/// Returns `true` if Warp should use an RC-file based bootstrap (e.g. dump the bootstrap script to
/// a temp file and `source` it) for a newly spawned session with the given `shell_type`, and
/// associated `session_type` and `subshell_initialization_info`.
@@ -57,6 +69,12 @@ pub fn should_use_rc_file_bootstrap_method(
) -> bool {
use super::ShellLaunchData;
// Container subshells cannot access host temp files, so the RC-file
// method is never viable for them.
if is_container_subshell(session_info) {
return false;
}
let session_type = &session_info.session_type;
match session_type {
BootstrapSessionType::Local => {
@@ -187,18 +205,38 @@ pub fn script_for_shell(shell_type: ShellType, assets: &dyn AssetProvider) -> Co
.into()
}
/// Generates a cryptographically random session ID for use as both a session
/// identifier and an integrity token for DCS hook validation.
pub fn generate_session_id() -> SessionId {
let mut rng = rand::thread_rng();
loop {
let session_id = rng.gen::<u64>();
if session_id != 0 {
return SessionId::from(session_id);
}
}
}
/// Placeholder in init shell scripts that gets replaced with the client-generated session ID.
pub(crate) const SESSION_ID_PLACEHOLDER: &str = "@@WARP_SESSION_ID@@";
/// Returns the init shell script for the given `shell_type` (e.g. the script that emits the
/// InitShell DCS hook).
///
/// The returned script is one line and, for shells that need it, has escaped single-quotes for the
/// purposes of being passed as a single-quoted argument to 'eval'.
pub fn init_shell_script_for_shell(shell_type: ShellType, assets: &dyn AssetProvider) -> String {
match shell_type {
pub fn init_shell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
let script = match shell_type {
ShellType::Zsh => load_and_escape_script("bundled/bootstrap/zsh_init_shell.sh", assets),
ShellType::Bash => load_and_escape_script("bundled/bootstrap/bash_init_shell.sh", assets),
ShellType::Fish => load_and_escape_script("bundled/bootstrap/fish_init_shell.sh", assets),
ShellType::PowerShell => load_script("bundled/bootstrap/pwsh_init_shell.ps1", assets),
}
};
script.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the command to be used to emit the InitShell hook for a new subshell session.
@@ -209,15 +247,16 @@ pub fn init_shell_script_for_shell(shell_type: ShellType, assets: &dyn AssetProv
pub fn init_subshell_command(
shell_type: Option<ShellType>,
vars: &[EnvVar],
session_id: SessionId,
ctx: &AppContext,
) -> String {
match shell_type {
Some(shell_type) => {
let subshell_script =
init_subshell_script_for_shell(shell_type, &crate::ASSETS, vars, ctx);
init_subshell_script_for_shell(shell_type, &crate::ASSETS, vars, session_id, ctx);
format!(r#" [ -z $WARP_BOOTSTRAPPED ] && eval '{subshell_script}'"#)
}
None => init_subshell_script_for_unknown_shell(&crate::ASSETS),
None => init_subshell_script_for_unknown_shell(&crate::ASSETS, session_id),
}
}
@@ -230,6 +269,7 @@ fn init_subshell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
env_vars: &[EnvVar],
session_id: SessionId,
ctx: &AppContext,
) -> String {
let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1;
@@ -258,6 +298,8 @@ fn init_subshell_script_for_shell(
// TODO(PLAT-750)
ShellType::PowerShell => todo!(),
};
let shell_init_script =
shell_init_script.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string());
// Combine the environment setup script with the shell-specific init script
format!("{env_setup_script} {shell_init_script}")
@@ -267,10 +309,14 @@ fn init_subshell_script_for_shell(
///
/// The returned script is one line and has escaped single-quotes for the purposes of being passed
/// as a single-quoted argument to 'eval'.
fn init_subshell_script_for_unknown_shell(assets: &dyn AssetProvider) -> String {
fn init_subshell_script_for_unknown_shell(
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
// Load and escape the shell-specific init script
load_and_escape_script("bundled/bootstrap/unknown_init_subshell.sh", assets)
.replace("HOOK_NAME", "InitSubshell")
.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the raw init shell script for the given `shell_type`, without
@@ -284,6 +330,7 @@ fn init_subshell_script_for_unknown_shell(assets: &dyn AssetProvider) -> String
pub fn raw_init_shell_script_for_shell(
shell_type: ShellType,
assets: &dyn AssetProvider,
session_id: SessionId,
) -> String {
let file = match shell_type {
ShellType::Bash => "bundled/bootstrap/bash_init_shell.sh",
@@ -291,7 +338,9 @@ pub fn raw_init_shell_script_for_shell(
ShellType::Fish => "bundled/bootstrap/fish_init_shell.sh",
ShellType::PowerShell => "bundled/bootstrap/pwsh_init_shell.ps1",
};
load_script(file, assets).replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string()))
load_script(file, assets)
.replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string()))
.replace(SESSION_ID_PLACEHOLDER, &session_id.as_u64().to_string())
}
/// Returns the script in the file at `file_path` to be passed as a single-quoted argument in the
@@ -323,5 +372,5 @@ fn load_script(file_path: &str, assets: &dyn AssetProvider) -> String {
}
#[cfg(test)]
#[path = "bootstrap_test.rs"]
#[path = "bootstrap_tests.rs"]
mod tests;
+24 -11
View File
@@ -1,9 +1,14 @@
use std::sync::Arc;
use enclose::enclose;
use itertools::Itertools as _;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon;
use galaxy_graphql::billing::AddonCreditsOption;
use galaxy_graphql::error::BudgetExceededError;
use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Expanded, Flex, FormattedTextElement, HighlightedHyperlink,
@@ -15,10 +20,6 @@ use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent as _, UiComponentStyles};
use galaxyui::{AppContext, Element, Entity, SingletonEntity as _, View, ViewContext, ViewHandle};
use itertools::Itertools as _;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::ai::request_usage_model::{
AIRequestUsageModel, AIRequestUsageModelEvent, BuyCreditsBannerDisplayState,
@@ -31,9 +32,8 @@ use crate::send_telemetry_from_ctx;
use crate::server::ids::ServerId;
use crate::server::telemetry::{OutOfCreditsBannerAction, TelemetryEvent};
use crate::settings_view::create_discount_badge;
use crate::view_components::Dropdown;
use crate::view_components::{Dropdown, DropdownAction};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
use galaxy_graphql::error::BudgetExceededError;
#[derive(Default)]
struct MouseStates {
@@ -137,6 +137,15 @@ impl BuyCreditsBanner {
.addon_credits_options
.get(self.selected_denomination_index)
.map(|option| option.credits);
let has_admin_permissions = {
let auth_state = AuthStateProvider::as_ref(ctx).get();
let current_team = UserWorkspaces::as_ref(ctx).current_team();
auth_state
.user_email()
.zip(current_team)
.map(|(email, team)| team.has_admin_permissions(&email))
.unwrap_or_default()
};
// Things we always do:
// - emit telemetry
@@ -161,7 +170,7 @@ impl BuyCreditsBanner {
// - Banner toggle flow: optionally enable auto-reload immediately.
// - Post-purchase modal flow: show the modal.
if banner_toggle_flag_enabled {
if self.auto_reload_enabled {
if has_admin_permissions && self.auto_reload_enabled {
self.banner_auto_reload_update_in_flight = true;
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
@@ -176,7 +185,7 @@ impl BuyCreditsBanner {
});
}
}
} else if post_purchase_modal_flag_enabled {
} else if has_admin_permissions && post_purchase_modal_flag_enabled {
// Default selection in the modal should match the denomination the user clicked "buy" on.
ctx.emit(BuyCreditsBannerEvent::OpenAutoReloadModal {
purchased_credits: selected_credits.unwrap_or(0),
@@ -359,11 +368,15 @@ impl BuyCreditsBanner {
})),
Some(primary_text)
)
.with_on_select_action(Action::SelectDenomination(index).into())
.with_on_select_action(DropdownAction::select_action_and_close(
Action::SelectDenomination(index),
))
.into_item()
} else {
MenuItemFields::new(primary_text.clone())
.with_on_select_action(Action::SelectDenomination(index).into())
.with_on_select_action(DropdownAction::select_action_and_close(
Action::SelectDenomination(index),
))
.into_item()
}
})
@@ -830,7 +843,7 @@ impl View for BuyCreditsBanner {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
SelectDenomination(usize),
Close,
+97 -17
View File
@@ -5,7 +5,6 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path;
use ai::skills::SkillProvider;
use enum_iterator::Sequence;
@@ -14,7 +13,11 @@ use markdown_parser::parse_markdown;
use pathfinder_color::ColorU;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use galaxy_cli::agent::Harness;
use galaxy_completer::parsers::simple::top_level_command;
use galaxy_editor::content::buffer::Buffer;
use galaxy_editor::content::markdown::MarkdownStyle;
use galaxy_util::path::EscapeChar;
use galaxyui::{AppContext, SingletonEntity};
use crate::ai::agent::{AgentReviewCommentBatch, DiffSetHunk};
@@ -24,8 +27,6 @@ use crate::code_review::comments::AttachedReviewCommentTarget;
use crate::server::telemetry::CLIAgentType;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_completer::parsers::simple::top_level_command;
use galaxy_util::path::EscapeChar;
/// UID for the Uber team.
/// See https://warp.metabaseapp.com/dashboard/1454?team_id=46347
@@ -40,7 +41,7 @@ pub(crate) const GEMINI_BLUE: ColorU = ColorU {
};
/// OpenAI brand color (dark gray/black)
const OPENAI_COLOR: ColorU = ColorU {
pub(crate) const OPENAI_COLOR: ColorU = ColorU {
r: 0,
g: 0,
b: 0,
@@ -64,7 +65,7 @@ const DROID_COLOR: ColorU = ColorU {
};
/// OpenCode brand color (gray, used for contrast calculation only)
const OPENCODE_COLOR: ColorU = ColorU {
pub(crate) const OPENCODE_COLOR: ColorU = ColorU {
r: 128,
g: 128,
b: 128,
@@ -87,6 +88,14 @@ const PI_COLOR: ColorU = ColorU {
a: 255,
};
/// Antigravity brand color (white, monochrome logo)
const ANTIGRAVITY_COLOR: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
/// Auggie brand color (white, monochrome logo)
const AUGGIE_COLOR: ColorU = ColorU {
r: 255,
@@ -103,7 +112,31 @@ const CURSOR_COLOR: ColorU = ColorU {
a: 255,
};
/// Represents a CLI agent (e.g., Claude Code, Gemini CLI, Codex, Amp, Droid, OpenCode, Copilot, Pi, Auggie, Cursor)
/// Goose brand color (#101010, from Block's official Goose logo)
const GOOSE_COLOR: ColorU = ColorU {
r: 16,
g: 16,
b: 16,
a: 255,
};
/// Hermes brand color (Nous Research purple #7C3AED)
const HERMES_PURPLE: ColorU = ColorU {
r: 124,
g: 58,
b: 237,
a: 255,
};
/// Mistral brand orange (#FA520F)
const MISTRAL_ORANGE: ColorU = ColorU {
r: 250,
g: 82,
b: 15,
a: 255,
};
/// Represents a CLI agent (e.g., Claude Code, Gemini CLI, Codex, Amp, Droid, OpenCode, Copilot, Pi, Auggie, Cursor, Goose, Hermes, Mistral Vibe)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Sequence, Serialize, Deserialize)]
pub enum CLIAgent {
Claude,
@@ -116,6 +149,10 @@ pub enum CLIAgent {
Pi,
Auggie,
CursorCli,
Goose,
Hermes,
Vibe,
Antigravity,
/// Represents an unknown/custom CLI agent matched by user-configured regex patterns.
Unknown,
}
@@ -134,6 +171,10 @@ impl CLIAgent {
CLIAgent::Pi => "pi",
CLIAgent::Auggie => "auggie",
CLIAgent::CursorCli => "agent",
CLIAgent::Goose => "goose",
CLIAgent::Hermes => "hermes",
CLIAgent::Vibe => "vibe",
CLIAgent::Antigravity => "agy",
CLIAgent::Unknown => "",
}
}
@@ -152,6 +193,20 @@ impl CLIAgent {
serde_json::from_value(name.into()).unwrap_or(CLIAgent::Unknown)
}
/// Returns the [`CLIAgent`] corresponding to a cloud-agent [`Harness`] when it represents a
/// third-party agent. Returns `None` for [`Harness::Oz`] (Warp's built-in harness has no
/// distinct CLI agent identity).
pub fn from_harness(harness: Harness) -> Option<Self> {
match harness {
Harness::Oz => None,
Harness::Claude => Some(CLIAgent::Claude),
Harness::Gemini => Some(CLIAgent::Gemini),
Harness::OpenCode => Some(CLIAgent::OpenCode),
Harness::Codex => Some(CLIAgent::Codex),
Harness::Unknown => Some(CLIAgent::Unknown),
}
}
pub fn display_name(&self) -> &'static str {
match self {
CLIAgent::Claude => "Claude Code",
@@ -164,6 +219,10 @@ impl CLIAgent {
CLIAgent::Pi => "Pi",
CLIAgent::Auggie => "Auggie",
CLIAgent::CursorCli => "Cursor",
CLIAgent::Goose => "Goose",
CLIAgent::Hermes => "Hermes",
CLIAgent::Vibe => "Mistral Vibe",
CLIAgent::Antigravity => "Antigravity",
CLIAgent::Unknown => "CLI Agent",
}
}
@@ -181,6 +240,13 @@ impl CLIAgent {
CLIAgent::Pi => Some(Icon::PiLogo),
CLIAgent::Auggie => Some(Icon::AuggieLogo),
CLIAgent::CursorCli => Some(Icon::CursorLogo),
CLIAgent::Goose => Some(Icon::GooseLogo),
CLIAgent::Hermes => None,
// Vibe is recognized but ships without a brand asset. The brand color
// still drives the toolbar tile; an `Icon::MistralLogo` can be wired
// up in a follow-up once an officially licensed SVG is available.
CLIAgent::Vibe => None,
CLIAgent::Antigravity => Some(Icon::AntigravityLogo),
CLIAgent::Unknown => None,
}
}
@@ -208,6 +274,10 @@ impl CLIAgent {
CLIAgent::Pi => &[SkillProvider::Agents],
CLIAgent::Auggie => &[SkillProvider::Agents],
CLIAgent::CursorCli => &[SkillProvider::Agents],
CLIAgent::Goose => &[SkillProvider::Agents],
CLIAgent::Hermes => &[SkillProvider::Agents],
CLIAgent::Vibe => &[SkillProvider::Agents],
CLIAgent::Antigravity => &[],
CLIAgent::Unknown => &[],
}
}
@@ -247,6 +317,10 @@ impl CLIAgent {
CLIAgent::Pi => Some(PI_COLOR),
CLIAgent::Auggie => Some(AUGGIE_COLOR),
CLIAgent::CursorCli => Some(CURSOR_COLOR),
CLIAgent::Goose => Some(GOOSE_COLOR),
CLIAgent::Hermes => Some(HERMES_PURPLE),
CLIAgent::Vibe => Some(MISTRAL_ORANGE),
CLIAgent::Antigravity => Some(ANTIGRAVITY_COLOR),
CLIAgent::Unknown => None,
}
}
@@ -255,7 +329,9 @@ impl CLIAgent {
/// Agents with light brand colors use a dark icon for contrast.
pub fn brand_icon_color(&self) -> ColorU {
match self {
CLIAgent::Pi | CLIAgent::Auggie | CLIAgent::Droid => ColorU::new(0, 0, 0, 255),
CLIAgent::Pi | CLIAgent::Auggie | CLIAgent::Droid | CLIAgent::Antigravity => {
ColorU::new(0, 0, 0, 255)
}
_ => ColorU::white(),
}
}
@@ -308,13 +384,15 @@ impl CLIAgent {
let resolved_first_word = Self::extract_first_command(&resolved_command, escape_char)?;
// Check if resolved command matches any known CLI agent.
// Also matches `aifx agent run claude` as Claude for Uber employees.
// Also matches `aifx agent run claude` as Claude for Uber employees,
// and the `vibe-acp` ACP-mode binary as Mistral Vibe.
enum_iterator::all::<CLIAgent>()
.filter(|agent| !matches!(agent, CLIAgent::Unknown))
.find(|agent| {
resolved_first_word == agent.command_prefix()
|| (matches!(agent, CLIAgent::Claude)
&& Self::is_aifx_agent_run_claude(&resolved_command, ctx))
|| (matches!(agent, CLIAgent::Vibe) && resolved_first_word == "vibe-acp")
})
}
@@ -362,7 +440,7 @@ pub fn build_review_prompt(review: &AgentReviewCommentBatch) -> String {
line,
..
} => {
let path = absolute_file_path.display();
let path = absolute_file_path.display_path();
match line {
EditorLineLocation::Current { line_number, .. } => {
let n = line_number.as_usize() + 1;
@@ -382,10 +460,9 @@ pub fn build_review_prompt(review: &AgentReviewCommentBatch) -> String {
}
}
AttachedReviewCommentTarget::File { absolute_file_path } => {
let path = absolute_file_path.display();
let abs_str = absolute_file_path.to_string_lossy();
let path = absolute_file_path.display_path();
let is_deleted = review.diff_set.iter().any(|(file_key, hunks)| {
abs_str.ends_with(file_key.as_str())
path.ends_with(file_key.as_str())
&& !hunks.is_empty()
&& hunks
.iter()
@@ -394,7 +471,7 @@ pub fn build_review_prompt(review: &AgentReviewCommentBatch) -> String {
if is_deleted {
format!("{path} (deleted file — see `git diff`)")
} else {
format!("{path}")
path
}
}
AttachedReviewCommentTarget::General => "General".to_string(),
@@ -430,15 +507,14 @@ fn export_review_comment_for_cli_prompt(comment: &str) -> String {
/// `<path> L<start>-L<end>` where `start` and `end` are 1-indexed and both
/// ends are **inclusive**.
pub fn build_diff_hunk_prompt(
file_path: &Path,
file_path: &str,
start_line: usize,
end_line: usize,
lines_added: u32,
lines_removed: u32,
) -> String {
let path = file_path.display();
format!(
"{path} L{start_line}-L{end_line} (+{lines_added} -{lines_removed}) \
"{file_path} L{start_line}-L{end_line} (+{lines_added} -{lines_removed}) \
-- run `git diff` to see the full context."
)
}
@@ -507,6 +583,10 @@ impl From<CLIAgent> for CLIAgentType {
CLIAgent::Pi => CLIAgentType::Pi,
CLIAgent::Auggie => CLIAgentType::Auggie,
CLIAgent::CursorCli => CLIAgentType::Cursor,
CLIAgent::Goose => CLIAgentType::Goose,
CLIAgent::Hermes => CLIAgentType::Hermes,
CLIAgent::Vibe => CLIAgentType::Vibe,
CLIAgent::Antigravity => CLIAgentType::Antigravity,
CLIAgent::Unknown => CLIAgentType::Unknown,
}
}
@@ -25,6 +25,15 @@ pub enum CLIAgentEventType {
Unknown(String),
}
/// How a CLI agent event reached Warp.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CLIAgentEventSource {
/// Structured OSC 777 notification from a rich plugin.
RichPlugin,
/// Native Codex OSC 9 fallback notification.
CodexOsc9Fallback,
}
/// Event-specific fields that vary by event type.
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
@@ -49,6 +58,7 @@ pub struct CLIAgentEvent {
pub cwd: Option<String>,
pub project: Option<String>,
pub payload: CLIAgentEventPayload,
pub source: CLIAgentEventSource,
}
/// Version-specific parsers, indexed by (version - 1).
@@ -1,9 +1,8 @@
use serde::Deserialize;
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType};
use crate::terminal::CLIAgent;
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
/// Resolves a CLI agent from the `"agent"` string in a CLI agent event.
/// Returns `None` if the string doesn't match any known agent.
fn resolve_agent(agent: &str) -> Option<CLIAgent> {
@@ -55,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
tool_input_preview,
plugin_version: raw.plugin_version,
},
source: CLIAgentEventSource::RichPlugin,
})
}
@@ -1,8 +1,10 @@
use galaxyui::{EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{CLIAgentEvent, CLIAgentSessionsModel};
use crate::terminal::cli_agent_sessions::event::parse_event;
use crate::terminal::cli_agent_sessions::event::{CLIAgentEventPayload, CLIAgentEventType};
use crate::features::FeatureFlag;
use crate::terminal::cli_agent_sessions::event::{
parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::CLIAgent;
@@ -14,27 +16,23 @@ trait CLIAgentSessionHandler {
/// The default implementation delegates to the structured JSON parser
/// (`parse_event`); agents with non-JSON notification formats (e.g. Codex
/// OSC 9 plain text) should override this.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
///
/// `plugin_already_active` is true when the session has already received a
/// structured OSC 777 notification; Codex uses it to drop OSC 9 fallback
/// once the rich plugin is active. Other handlers ignore it.
fn try_parse(
&mut self,
title: Option<&str>,
body: &str,
plugin_already_active: bool,
) -> Option<CLIAgentEvent> {
let _ = plugin_already_active;
parse_event(title, body)
}
/// Decide whether a parsed event should be forwarded to the sessions model.
/// Returns the event (possibly transformed) if it should be processed.
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent>;
/// Whether this handler provides meaningful, fine-grained status
/// (e.g. in-progress / blocked / success) that should be shown in the UI.
/// Handlers backed by the structured plugin protocol report rich status;
/// handlers that only forward opaque OS notifications (e.g. Codex) do not.
fn supports_rich_status(&self) -> bool {
true
}
}
/// Whether the listener for the given agent provides rich status.
/// Returns `false` for agents without a handler or whose handler opts out.
pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool {
create_handler(agent).is_some_and(|h| h.supports_rich_status())
}
/// Returns `true` if the given CLI agent has a supported session handler.
@@ -46,25 +44,35 @@ pub fn is_agent_supported(agent: &CLIAgent) -> bool {
| CLIAgent::Codex
| CLIAgent::Gemini
| CLIAgent::Auggie
| CLIAgent::Droid
| CLIAgent::Pi
)
}
/// Creates the appropriate handler for the given CLI agent.
fn create_handler(agent: &CLIAgent) -> Option<Box<dyn CLIAgentSessionHandler>> {
match agent {
// Auggie is supported via the community-maintained auggie-warp plugin
// (https://github.com/augmentmoogi/auggie-warp), which emits the same
// Auggie and Pi are supported via community-maintained plugins
// (https://github.com/augmentmoogi/auggie-warp,
// https://github.com/badlogic/pi-mono), which emit the same
// structured OSC 777 events as the first-party Claude/OpenCode/Gemini
// plugins. We don't ship an install flow for it — we just listen.
CLIAgent::Claude | CLIAgent::OpenCode | CLIAgent::Gemini | CLIAgent::Auggie => {
Some(Box::new(DefaultSessionListener))
}
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
CLIAgent::Amp
// plugins. Droid can be supported by user-configured hooks or future
// integrations that emit the same structured OSC 777 events. We don't
// ship install flows for these agents here — we just listen.
CLIAgent::Claude
| CLIAgent::OpenCode
| CLIAgent::Gemini
| CLIAgent::Auggie
| CLIAgent::Droid
| CLIAgent::Pi => Some(Box::new(DefaultSessionListener)),
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
CLIAgent::Hermes
| CLIAgent::Amp
| CLIAgent::Copilot
| CLIAgent::Pi
| CLIAgent::CursorCli
| CLIAgent::Goose
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Unknown => None,
}
}
@@ -84,14 +92,11 @@ impl CLIAgentSessionHandler for DefaultSessionListener {
}
}
/// Codex-specific handler that parses plain-text OSC 9 desktop notifications
/// into CLI agent events.
/// Codex-specific handler that supports both native OSC 9 fallback and structured plugin events.
///
/// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with
/// human-readable text. Since there's no way to distinguish notification types
/// from the raw text, all OSC 9 notifications are treated as `Stop` (success).
/// The notification body becomes the event's `query` so it surfaces as the
/// notification title in the UI.
/// human-readable text. Since there's no way to distinguish notification types from the raw text,
/// OSC 9 fallback notifications are treated as `Stop` (success).
struct CodexSessionHandler;
impl CodexSessionHandler {
@@ -114,22 +119,34 @@ impl CodexSessionHandler {
query: Some(body.to_owned()),
..Default::default()
},
source: CLIAgentEventSource::CodexOsc9Fallback,
})
}
}
impl CLIAgentSessionHandler for CodexSessionHandler {
/// Codex sends plain-text OSC 9 notifications (title = `None`) instead of
/// the structured OSC 777 JSON used by Claude Code / OpenCode.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
// If the notification carries the structured sentinel, try the normal
// JSON parser first (future-proofing in case Codex adds plugin
// support later).
if let Some(parsed) = parse_event(title, body) {
return Some(parsed);
/// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp.
/// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one.
/// This lets us ignore OSC 9 notifications if we are working with a client that is using
/// the new plugin, but keeps them intact for legacy clients.
fn try_parse(
&mut self,
title: Option<&str>,
body: &str,
plugin_already_active: bool,
) -> Option<CLIAgentEvent> {
if let Some(event) = parse_event(title, body) {
if event.agent == CLIAgent::Codex {
if !FeatureFlag::CodexPlugin.is_enabled() {
return None;
}
return Some(event);
}
return None;
}
// OSC 9 notifications have no title.
if title.is_some() {
// OSC 9 notifications have no title. Skip OSC 9 once the rich plugin is
// active, otherwise we'd process both OSC 777 and OSC 9 notifications.
if title.is_some() || plugin_already_active {
return None;
}
Self::parse_osc9_text(body)
@@ -138,10 +155,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler {
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
Some(event)
}
fn supports_rich_status(&self) -> bool {
false
}
}
/// Per-agent listener that subscribes to PTY events and forwards them to the
@@ -169,14 +182,21 @@ impl CLIAgentSessionListener {
// Subscribe to subsequent OSC events from this terminal's PTY.
// Parsing is delegated to the handler's `try_parse`; the handler's
// `handle_event` then filters/transforms the result.
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| {
ctx.subscribe_to_model(model_event_dispatcher, move |me, _, event, ctx| {
if let ModelEvent::PluggableNotification { title, body } = event {
let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else {
let view_id = me.terminal_view_id;
let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx)
.session(view_id)
.is_some_and(|session| session.received_rich_notification);
let Some(parsed) =
me.inner
.try_parse(title.as_deref(), body, plugin_already_active)
else {
return;
};
if let Some(event) = me.inner.handle_event(parsed) {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
sessions_model.update_from_event(me.terminal_view_id, &event, ctx);
sessions_model.update_from_event(view_id, &event, ctx);
});
}
}
@@ -190,100 +210,5 @@ impl CLIAgentSessionListener {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::cli_agent_sessions::event::CLIAgentEventType;
#[test]
fn codex_parses_any_text_as_stop() {
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(event.agent, CLIAgent::Codex);
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
}
#[test]
fn codex_body_becomes_query() {
let event = CodexSessionHandler::parse_osc9_text(
"I've updated the README with the new instructions.",
)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("I've updated the README with the new instructions.")
);
}
#[test]
fn codex_approval_text_still_becomes_stop() {
let event =
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("Approval requested: rm -rf /tmp/foo")
);
}
#[test]
fn codex_ignores_empty_body() {
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
}
#[test]
fn codex_try_parse_ignores_titled_notifications() {
let handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete")
.is_none());
}
#[test]
fn codex_try_parse_handles_osc9() {
let handler = CodexSessionHandler;
let event = handler.try_parse(None, "Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}
#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
}
#[test]
fn auggie_uses_default_handler_with_rich_status() {
assert!(agent_supports_rich_status(&CLIAgent::Auggie));
}
#[test]
fn auggie_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
}
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,229 @@
use super::*;
use crate::terminal::cli_agent_sessions::event::{
CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL,
};
#[test]
fn codex_parses_any_text_as_stop() {
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(event.agent, CLIAgent::Codex);
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
}
#[test]
fn codex_body_becomes_query() {
let event =
CodexSessionHandler::parse_osc9_text("I've updated the README with the new instructions.")
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("I've updated the README with the new instructions.")
);
}
#[test]
fn codex_approval_text_still_becomes_stop() {
let event =
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("Approval requested: rm -rf /tmp/foo")
);
}
#[test]
fn codex_ignores_empty_body() {
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
}
#[test]
fn codex_try_parse_ignores_titled_notifications() {
let mut handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete", false)
.is_none());
}
#[test]
fn codex_try_parse_handles_osc9() {
let mut handler = CodexSessionHandler;
let event = handler
.try_parse(None, "Agent turn complete", false)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}
#[test]
fn codex_try_parse_ignores_osc9_when_plugin_already_active() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;
let event = handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::PermissionRequest);
// Once the session is rich, OSC 9 fallback is dropped.
assert!(handler
.try_parse(None, "Agent turn complete", true)
.is_none());
}
#[test]
fn codex_try_parse_ignores_structured_event_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;
assert!(handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.is_none());
assert!(handler
.try_parse(None, "Agent turn complete", false)
.is_some());
}
#[test]
fn codex_try_parse_ignores_other_structured_agents() {
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
assert!(handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.is_none());
assert!(handler
.try_parse(None, "Agent turn complete", false)
.is_some());
}
#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
}
#[test]
fn auggie_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn pi_is_supported() {
assert!(is_agent_supported(&CLIAgent::Pi));
}
#[test]
fn pi_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Pi,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn pi_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Pi,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn droid_is_supported() {
assert!(is_agent_supported(&CLIAgent::Droid));
}
#[test]
fn droid_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn droid_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn droid_default_handler_forwards_permission_request() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
+43 -7
View File
@@ -5,13 +5,12 @@ pub(crate) mod plugin_manager;
use std::collections::{HashMap, HashSet};
use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::blocklist::InputConfig;
use self::listener::CLIAgentSessionListener;
use super::CLIAgent;
use event::{CLIAgentEvent, CLIAgentEventType};
use crate::ai::blocklist::InputConfig;
/// Status of a tracked CLI agent session.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -123,12 +122,12 @@ pub struct CLIAgentSession {
pub input_state: CLIAgentInputState,
/// Whether status-driven auto-toggle is enabled for this session.
pub should_auto_toggle_input: bool,
/// Plugin-backed event listener, if the CLI agent plugin is installed.
/// `None` for sessions created by command detection alone.
/// Event listener for plugin-backed sessions or Codex OSC9 fallback.
/// `None` for non-Codex sessions created by command detection alone.
/// Dropping this handle cleans up the listener's PTY event subscription.
pub listener: Option<ModelHandle<CLIAgentSessionListener>>,
/// The plugin version reported by the `SessionStart` event.
/// `None` if the plugin predates version reporting or hasn't connected yet.
/// The plugin version reported by structured plugin events.
/// `None` if the plugin predates version reporting or Codex is using OSC9 fallback.
pub plugin_version: Option<String>,
/// `None` when the session is local.
/// `Some("user@hostname")` when running over SSH (warpified or legacy).
@@ -141,6 +140,10 @@ pub struct CLIAgentSession {
/// the first word of the command (the binary/alias the user typed).
/// Used to customize plugin instructions and force manual install mode.
pub custom_command_prefix: Option<String>,
/// Set once the session has received any structured OSC 777 (rich)
/// notification. Codex's OSC 9 fallback never sets it, so this is the
/// single source of truth for whether the session is plugin-backed.
pub received_rich_notification: bool,
}
impl CLIAgentSession {
@@ -148,6 +151,28 @@ impl CLIAgentSession {
self.remote_host.is_some()
}
/// Whether the session surfaces trustworthy fine-grained status
/// (in-progress / blocked / success). True only after receiving a rich OSC
/// 777 notification. Codex's OSC 9 fallback emits only opaque `Stop`
/// notifications and never sets `received_rich_notification`, so it does
/// not qualify. Synthetic listener registration also does not qualify until
/// an actual rich notification arrives.
pub fn supports_rich_status(&self) -> bool {
self.received_rich_notification
}
/// Clears state populated by `PermissionRequest`. Called whenever the
/// session leaves the permission flow (the user replied, a blocking tool
/// completed, a new prompt is submitted, or the session ends successfully)
/// so the permission summary doesn't leak into later UI surfaces — most
/// visibly the tab title, which can fall back to `summary` when `query`
/// is unset.
fn clear_permission_scoped_state(&mut self) {
self.session_context.summary = None;
self.session_context.tool_name = None;
self.session_context.tool_input_preview = None;
}
/// Applies an event to this session, updating context and status.
/// Returns the new status if it changed, or `None` if the event was irrelevant.
fn apply_event(&mut self, event: &CLIAgentEvent) -> Option<CLIAgentSessionStatus> {
@@ -165,17 +190,20 @@ impl CLIAgentSession {
CLIAgentEventType::PromptSubmit => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = None;
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::ToolComplete => {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::Stop => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = event.payload.response.clone();
self.clear_permission_scoped_state();
CLIAgentSessionStatus::Success
}
CLIAgentEventType::PermissionRequest => {
@@ -197,6 +225,7 @@ impl CLIAgentSession {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
// IdlePrompt means the agent is sitting at its prompt waiting for input.
@@ -364,6 +393,7 @@ impl CLIAgentSessionsModel {
remote_host,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
@@ -379,6 +409,8 @@ impl CLIAgentSessionsModel {
}
/// Updates the session's status and context from a parsed CLI agent event.
/// Rich plugin events latch `received_rich_notification` so rich-status
/// surfaces stay consistent even if the first event was not SessionStart.
pub fn update_from_event(
&mut self,
terminal_view_id: EntityId,
@@ -389,6 +421,10 @@ impl CLIAgentSessionsModel {
return;
};
if event.source == CLIAgentEventSource::RichPlugin {
session.received_rich_notification = true;
}
let event_type = &event.event;
if let Some(new_status) = session.apply_event(event) {
let agent = session.agent;
@@ -1,4 +1,6 @@
use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
use super::event::{
parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
};
use super::{
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext,
CLIAgentSessionStatus, CLIAgentSessionsModel,
@@ -222,6 +224,34 @@ fn parse_auggie_stop_notification() {
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn parse_pi_stop_notification() {
// Mirrors what the community pi-mono plugin emits on the Stop hook —
// matches the Auggie shape and uses `"agent":"pi"`, which `resolve_agent`
// already maps to `CLIAgent::Pi` via `command_prefix()`.
let body = r#"{"v":1,"agent":"pi","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.agent, CLIAgent::Pi);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn parse_droid_stop_notification() {
// Droid is already a known CLI agent, so structured OSC 777 events using
// `"agent":"droid"` should resolve through the existing command prefix
// parser without any Droid-specific parser logic.
let body = r#"{"v":1,"agent":"droid","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.agent, CLIAgent::Droid);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn apply_event_preserves_input_session() {
let input_state = CLIAgentInputState::Open {
@@ -243,9 +273,11 @@ fn apply_event_preserves_input_session() {
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
};
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
@@ -276,6 +308,7 @@ fn is_remote_returns_true_when_remote_host_is_set() {
draft_text: None,
remote_host: Some("user@devbox".to_owned()),
custom_command_prefix: None,
received_rich_notification: false,
};
assert!(session.is_remote());
}
@@ -293,6 +326,7 @@ fn is_remote_returns_false_when_remote_host_is_none() {
plugin_version: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
};
assert!(!session.is_remote());
}
@@ -361,9 +395,11 @@ fn session_start_sets_plugin_version() {
draft_text: None,
remote_host: None,
custom_command_prefix: None,
received_rich_notification: false,
};
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::SessionStart,
@@ -393,9 +429,11 @@ fn session_start_without_plugin_version_leaves_none() {
draft_text: None,
remote_host: None,
custom_command_prefix: None,
received_rich_notification: false,
};
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::SessionStart,
@@ -408,3 +446,254 @@ fn session_start_without_plugin_version_leaves_none() {
session.apply_event(&event);
assert_eq!(session.plugin_version, None);
}
#[test]
fn codex_session_not_rich_until_rich_notification() {
// Codex's OSC 9 fallback never sets `received_rich_notification`, so the
// session must not claim rich status even when a fallback listener exists.
let mut session = CLIAgentSession {
agent: CLIAgent::Codex,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
remote_host: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
};
assert!(!session.supports_rich_status());
// A structured OSC 777 notification latches the flag -> rich status.
session.received_rich_notification = true;
assert!(session.supports_rich_status());
}
#[test]
fn non_codex_session_rich_after_rich_notification() {
let mut session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
remote_host: None,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
};
// No listener and no rich notification yet.
assert!(!session.supports_rich_status());
session.received_rich_notification = true;
assert!(session.supports_rich_status());
}
/// Constructs a session with permission-scoped state already populated, as if
/// a `PermissionRequest` had just been received and the agent is now Blocked.
/// Used by the GH-9525 regression tests below.
fn blocked_claude_session_with_permission_state() -> CLIAgentSession {
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::Blocked {
message: Some("Wants to run bash: rm -rf /tmp".to_owned()),
},
session_context: CLIAgentSessionContext {
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
tool_name: Some("Bash".to_owned()),
tool_input_preview: Some("rm -rf /tmp".to_owned()),
..Default::default()
},
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: None,
custom_command_prefix: None,
received_rich_notification: false,
}
}
#[test]
fn stop_clears_permission_scoped_state() {
// GH-9525: after a PermissionRequest sets `summary`, the Stop event must
// clear it. Otherwise the tab title falls back to the stale permission
// text instead of reflecting the now-completed session.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::Stop,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some("write a haiku".to_owned()),
response: Some("Memory is safe".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert_eq!(
session.session_context.query.as_deref(),
Some("write a haiku"),
);
assert_eq!(
session.session_context.response.as_deref(),
Some("Memory is safe"),
);
assert!(matches!(session.status, CLIAgentSessionStatus::Success));
}
#[test]
fn permission_replied_clears_permission_scoped_state() {
// When the user replies to a permission prompt the agent transitions back
// to InProgress; the now-stale summary/tool fields must be cleared so they
// don't leak into UI surfaces during the next turn.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionReplied,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn prompt_submit_clears_permission_scoped_state() {
// PromptSubmit already clears `response`; clearing the permission-scoped
// fields keeps the same hygiene if the user manages to start a new turn
// while permission state is still populated (e.g. an abandoned permission
// flow that was not closed by an explicit PermissionReplied).
let mut session = blocked_claude_session_with_permission_state();
session.session_context.response = Some("stale response".to_owned());
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PromptSubmit,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some("next prompt".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert_eq!(session.session_context.response, None);
assert_eq!(
session.session_context.query.as_deref(),
Some("next prompt")
);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn tool_complete_clears_permission_scoped_state() {
// GH-11082: answering an AskUserQuestion emits only ToolComplete (the
// plugin sends no PermissionReplied for it), so the Blocked -> InProgress
// transition here must also clear the stale summary. Otherwise the tab
// title keeps showing "Wants to run AskUserQuestion: ..." until the next
// prompt or Stop.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::ToolComplete,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn permission_request_still_populates_summary_and_tool_fields() {
// Sanity: clearing permission-scoped state on Stop/Reply/Submit must not
// also break the PermissionRequest path that initially populates them.
let mut session = CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::InProgress,
session_context: CLIAgentSessionContext::default(),
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: None,
custom_command_prefix: None,
received_rich_notification: false,
};
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionRequest,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
tool_name: Some("Bash".to_owned()),
tool_input_preview: Some("rm -rf /tmp".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(
session.session_context.summary.as_deref(),
Some("Wants to run bash: rm -rf /tmp"),
);
assert_eq!(session.session_context.tool_name.as_deref(), Some("Bash"));
assert_eq!(
session.session_context.tool_input_preview.as_deref(),
Some("rm -rf /tmp"),
);
assert!(matches!(
session.status,
CLIAgentSessionStatus::Blocked { .. },
));
}
@@ -1,9 +1,7 @@
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::{env, fs, io};
use async_trait::async_trait;
use serde_json::Value;
@@ -16,16 +14,16 @@ use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const PLUGIN_KEY: &str = "warp@claude-code-warp";
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
const MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp";
const MARKETPLACE_NAME: &str = "claude-code-warp";
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
// Note: we will eventually publish this to the same marketplace repo, but are using the internal one as we build out multi-harness.
const PLATFORM_MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp-internal";
// Keep in sync with the plugin version in warpdotdev/claude-code-warp.
// (See the Versioning section of that repo's README.)
const MINIMUM_PLUGIN_VERSION: &str = "2.0.0";
const MINIMUM_PLUGIN_VERSION: &str = "2.1.0";
// Keep in sync with the oz-harness-support plugin version in warpdotdev/claude-code-warp.
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "1.1.2";
pub(super) struct ClaudeCodePluginManager {
executor: LocalCommandExecutor,
@@ -71,6 +69,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
check_installed(&claude_dir)
}
fn is_platform_plugin_installed(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
check_platform_plugin_installed(&claude_dir)
}
fn platform_plugin_needs_update(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
match installed_platform_plugin_version(&claude_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt(),
// No version field means very old plugin.
None => check_platform_plugin_installed(&claude_dir),
}
}
fn has_local_marketplace_override(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
claude_code_marketplace_has_local_override(&claude_dir)
}
/// Runs `claude plugin` CLI commands via the session shell.
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
@@ -151,7 +174,7 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
@@ -159,6 +182,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
.await?;
Ok(())
}
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let still_outdated = claude_home_dir()
.ok()
.and_then(|dir| installed_platform_plugin_version(&dir))
.map(|v| compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
.unwrap_or(true);
if still_outdated {
log.push_str("Post-update version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
@@ -214,6 +262,14 @@ static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plug
});
fn check_installed(claude_dir: &Path) -> bool {
check_plugin_installed(claude_dir, PLUGIN_KEY)
}
fn check_platform_plugin_installed(claude_dir: &Path) -> bool {
check_plugin_installed(claude_dir, PLATFORM_PLUGIN_KEY)
}
fn check_plugin_installed(claude_dir: &Path, plugin_key: &str) -> bool {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let Ok(contents) = fs::read_to_string(plugins_path) else {
return false;
@@ -223,7 +279,7 @@ fn check_installed(claude_dir: &Path) -> bool {
};
parsed
.get("plugins")
.and_then(|p| p.get(PLUGIN_KEY))
.and_then(|p| p.get(plugin_key))
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
@@ -231,12 +287,21 @@ fn check_installed(claude_dir: &Path) -> bool {
/// Reads the installed version string for the Warp plugin, if present.
fn installed_version(claude_dir: &Path) -> Option<String> {
installed_plugin_version(claude_dir, PLUGIN_KEY)
}
/// Reads the installed version string for the Oz platform plugin, if present.
fn installed_platform_plugin_version(claude_dir: &Path) -> Option<String> {
installed_plugin_version(claude_dir, PLATFORM_PLUGIN_KEY)
}
fn installed_plugin_version(claude_dir: &Path, plugin_key: &str) -> Option<String> {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let contents = fs::read_to_string(plugins_path).ok()?;
let parsed: Value = serde_json::from_str(&contents).ok()?;
parsed
.get("plugins")?
.get(PLUGIN_KEY)?
.get(plugin_key)?
.as_array()?
.first()?
.get("version")?
@@ -244,10 +309,55 @@ fn installed_version(claude_dir: &Path) -> Option<String> {
.map(|s| s.to_owned())
}
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
fn claude_code_marketplace_has_local_override(claude_dir: &Path) -> bool {
let settings_path = claude_dir.join("settings.json");
let Ok(contents) = fs::read_to_string(settings_path) else {
return false;
};
let Ok(settings) = serde_json::from_str::<Value>(&contents) else {
return false;
};
settings
.get("extraKnownMarketplaces")
.and_then(|marketplaces| marketplaces.get(MARKETPLACE_NAME))
.map(marketplace_entry_has_local_path)
.unwrap_or(false)
}
fn marketplace_entry_has_local_path(entry: &Value) -> bool {
let Some(source) = entry.get("source") else {
return false;
};
match source {
Value::Object(source) => {
let source_kind = source.get("source").and_then(Value::as_str);
let path = source.get("path").and_then(Value::as_str);
source_kind == Some("directory") && path.map(is_local_marketplace_path).unwrap_or(false)
}
Value::String(source) => is_local_marketplace_path(source),
_ => false,
}
}
fn is_local_marketplace_path(source: &str) -> bool {
source.starts_with('/')
|| source.starts_with("~/")
|| source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with("file://")
}
/// Resolves the dir the Claude CLI reads/writes its state from.
///
/// Honors `CLAUDE_CONFIG_DIR` (respected by the Claude CLI, and set by the Oz
/// worker to a per-task dir), falling back to `~/.claude`. Must match where
/// `claude plugin install` writes, else install/verify checks read the wrong dir.
fn claude_home_dir() -> io::Result<PathBuf> {
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
return Ok(PathBuf::from(claude_home));
if let Ok(dir) = env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
dirs::home_dir()
.map(|home| home.join(".claude"))
@@ -1,6 +1,27 @@
use std::fs;
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
use super::{
check_installed, check_platform_plugin_installed, claude_code_marketplace_has_local_override,
installed_platform_plugin_version, installed_version, ClaudeCodePluginManager,
CliAgentPluginManager, MINIMUM_PLATFORM_PLUGIN_VERSION,
};
/// A version strictly below `version`, so below-minimum tests track the
/// constant instead of a hardcoded literal. Assumes `version` > "0.0.0".
fn version_below(version: &str) -> String {
let mut parts: Vec<u64> = version.split('.').map(|p| p.parse().unwrap_or(0)).collect();
for part in parts.iter_mut().rev() {
if *part > 0 {
*part -= 1;
break;
}
}
parts
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(".")
}
#[test]
fn installed_when_plugin_present() {
@@ -22,6 +43,212 @@ fn installed_when_plugin_present() {
assert!(check_installed(dir.path()));
}
#[test]
fn local_marketplace_override_detects_directory_source() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": {
"path": "/Users/example/Developer/claude-code-warp-internal",
"source": "directory"
}
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
assert!(claude_code_marketplace_has_local_override(dir.path()));
}
#[test]
fn local_marketplace_override_ignores_repo_source() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": "warpdotdev/claude-code-warp"
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
assert!(!claude_code_marketplace_has_local_override(dir.path()));
}
#[test]
#[serial_test::serial]
fn local_marketplace_override_via_trait_uses_claude_config_dir() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": {
"path": "../claude-code-warp-internal",
"source": "directory"
}
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).has_local_marketplace_override();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
fn installed_platform_plugin_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(
installed_platform_plugin_version(dir.path()).as_deref(),
Some(MINIMUM_PLATFORM_PLUGIN_VERSION)
);
}
#[test]
fn platform_plugin_installed_when_platform_plugin_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(check_platform_plugin_installed(dir.path()));
}
#[test]
#[serial_test::serial]
fn platform_plugin_needs_update_via_trait_when_version_below_minimum() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": version_below(MINIMUM_PLATFORM_PLUGIN_VERSION)}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
#[serial_test::serial]
fn platform_plugin_does_not_need_update_via_trait_when_current() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn platform_plugin_needs_update_via_trait_when_installed_without_version() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"scope": "user"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
fn platform_plugin_not_installed_when_only_notification_plugin_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_platform_plugin_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_key_absent() {
let dir = tempfile::tempdir().unwrap();
@@ -95,10 +322,10 @@ fn not_installed_when_plugins_key_missing() {
}
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
/// `CLAUDE_CONFIG_DIR` at a temp directory with a valid installed_plugins.json.
#[test]
#[serial_test::serial]
fn is_installed_via_trait_with_claude_home_env() {
fn is_installed_via_trait_with_claude_config_dir_env() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
@@ -114,38 +341,25 @@ fn is_installed_via_trait_with_claude_home_env() {
)
.unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
#[serial_test::serial]
fn not_installed_via_trait_when_claude_home_empty() {
fn not_installed_via_trait_when_claude_config_dir_empty() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(!result);
}
#[test]
fn can_auto_install_is_true() {
assert!(ClaudeCodePluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn minimum_version() {
assert_eq!(
ClaudeCodePluginManager::new(None, None, None).minimum_plugin_version(),
"2.0.0"
);
}
#[test]
fn installed_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
@@ -1,54 +1,317 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::{env, fs, io};
use async_trait::async_trait;
use serde_json::Value;
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
use super::{
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
PluginInstructionStep, PluginInstructions,
};
use crate::features::FeatureFlag;
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
pub(super) struct CodexPluginManager;
const PLUGIN_NAME: &str = "warp";
const PLUGIN_KEY: &str = "warp@codex-warp";
const MARKETPLACE_REPO: &str = "warpdotdev/codex-warp";
const MARKETPLACE_NAME: &str = "codex-warp";
const PLATFORM_PLUGIN_NAME: &str = "orchestration";
const PLATFORM_PLUGIN_KEY: &str = "orchestration@codex-warp";
const CODEX_CONFIG_DIR: &str = ".codex";
const CODEX_HOME_ENV: &str = "CODEX_HOME";
// Keep in sync with the plugin version in warpdotdev/codex-warp.
const MINIMUM_PLUGIN_VERSION: &str = "0.4.0";
// Keep in sync with the orchestration plugin version in warpdotdev/codex-warp.
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "0.4.0";
pub(super) struct CodexPluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl CodexPluginManager {
pub(super) fn new(
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> Self {
let shell_type = shell_type.unwrap_or(ShellType::Bash);
Self {
executor: LocalCommandExecutor::new(shell_path, shell_type),
path_env_var,
}
}
async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> {
let env_vars = self
.path_env_var
.as_deref()
.map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())]));
run_cli_command_logged("codex", args, &self.executor, env_vars, log).await
}
/// Ensures the codex-warp marketplace is registered, while preserving a
/// non-Git/local marketplace override. If the marketplace is already a
/// Git repo, upgrade it; if it is a non-Git source, leave it alone; otherwise
/// add it from the canonical repository.
async fn ensure_marketplace(&self, log: &mut String) -> Result<(), PluginInstallError> {
match codex_home_dir()
.ok()
.and_then(|dir| codex_warp_marketplace_config(&dir))
{
Some(config) if config.is_git() => {
self.run_logged(&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], log)
.await
}
Some(_) => Ok(()),
None => {
self.run_logged(&["plugin", "marketplace", "add", MARKETPLACE_REPO], log)
.await
}
}
}
}
#[async_trait]
impl CliAgentPluginManager for CodexPluginManager {
fn minimum_plugin_version(&self) -> &'static str {
"0.0.0"
if FeatureFlag::CodexPlugin.is_enabled() {
MINIMUM_PLUGIN_VERSION
} else {
"0.0.0"
}
}
fn can_auto_install(&self) -> bool {
false
FeatureFlag::CodexPlugin.is_enabled()
}
fn supports_update(&self) -> bool {
false
fn is_installed(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
check_installed(&codex_dir)
}
fn needs_update(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
return false;
}
plugin_needs_update(&codex_dir, PLUGIN_NAME, PLUGIN_KEY, MINIMUM_PLUGIN_VERSION)
}
fn is_platform_plugin_installed(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
check_platform_plugin_installed(&codex_dir)
}
fn platform_plugin_needs_update(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
return false;
}
plugin_needs_update(
&codex_dir,
PLATFORM_PLUGIN_NAME,
PLATFORM_PLUGIN_KEY,
MINIMUM_PLATFORM_PLUGIN_VERSION,
)
}
fn has_local_marketplace_override(&self) -> bool {
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git())
}
async fn install(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
log::info!("[PLUGIN_INSTALL] updating codex plugin");
let mut log = String::new();
ensure_codex_home_dir()?;
self.ensure_marketplace(&mut log).await?;
self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log)
.await?;
Ok(())
}
async fn update(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
let mut log = String::new();
ensure_codex_home_dir()?;
self.run_logged(
&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME],
&mut log,
)
.await?;
self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log)
.await?;
let still_outdated = codex_home_dir()
.ok()
.and_then(|dir| installed_version(&dir))
.map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt())
.unwrap_or(true);
if still_outdated {
log.push_str("Post-update version check: plugin is still outdated\n");
return Err(PluginInstallError {
message: "Plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
fn install_success_message(&self) -> &'static str {
"Warp plugin installed. Please restart Codex to activate."
}
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please restart Codex to activate."
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
if FeatureFlag::CodexPlugin.is_enabled() {
&PLUGIN_INSTALL_INSTRUCTIONS
} else {
&NATIVE_INSTALL_INSTRUCTIONS
}
}
fn update_instructions(&self) -> &'static PluginInstructions {
&EMPTY_INSTRUCTIONS
if FeatureFlag::CodexPlugin.is_enabled() {
&PLUGIN_UPDATE_INSTRUCTIONS
} else {
&EMPTY_INSTRUCTIONS
}
}
fn supports_update(&self) -> bool {
FeatureFlag::CodexPlugin.is_enabled()
}
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
let mut log = String::new();
ensure_codex_home_dir()?;
self.ensure_marketplace(&mut log).await?;
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let updated = codex_home_dir()
.ok()
.map(|dir| platform_plugin_version_is_current(&dir))
.unwrap_or(false);
if !updated {
log.push_str("Post-install version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin installation did not take effect".to_owned(),
log,
});
}
Ok(())
}
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
let mut log = String::new();
ensure_codex_home_dir()?;
self.run_logged(
&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME],
&mut log,
)
.await?;
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let updated = codex_home_dir()
.ok()
.map(|dir| platform_plugin_version_is_current(&dir))
.unwrap_or(false);
if !updated {
log.push_str("Post-update version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> =
LazyLock::new(|| PluginInstructions {
title: "Install Warp Plugin for Codex",
subtitle: "Run the following commands, then restart Codex.",
steps: &[
PluginInstructionStep {
description: "Add the Warp plugin marketplace repository",
command: "codex plugin marketplace add warpdotdev/codex-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Install the Warp plugin",
command: "codex plugin add warp@codex-warp",
executable: true,
link: None,
},
],
post_install_notes: &["Restart Codex to activate the plugin."],
});
static NATIVE_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Enable Galaxy Notifications for Codex",
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Galaxy can display them while you work.",
steps: &[
PluginInstructionStep {
description: "Update Codex to the latest version.",
command: "",
executable: false,
link: Some("https://developers.openai.com/codex/cli#upgrade"),
},
PluginInstructionStep {
description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:",
command: "[tui]\nnotification_condition = \"always\"",
executable: false,
link: None,
},
],
post_install_notes: &["Restart Codex to apply the changes."],
}
title: "Enable Warp Notifications for Codex",
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.",
steps: &[
PluginInstructionStep {
description: "Update Codex to the latest version.",
command: "",
executable: false,
link: Some("https://developers.openai.com/codex/cli#upgrade"),
},
PluginInstructionStep {
description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:",
command: "[tui]\nnotification_condition = \"always\"",
executable: false,
link: None,
},
],
post_install_notes: &["Restart Codex to apply the changes."],
}
});
static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
@@ -58,6 +321,172 @@ static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plugi
post_install_notes: &[],
});
static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Update Warp Plugin for Codex",
subtitle: "Run the following commands, then restart Codex.",
steps: &[
PluginInstructionStep {
description: "Upgrade the marketplace",
command: "codex plugin marketplace upgrade codex-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Reinstall the Warp plugin",
command: "codex plugin add warp@codex-warp",
executable: true,
link: None,
},
],
post_install_notes: &[
"Restart Codex to activate the update.",
"If this fails because codex-warp is not configured as a Git marketplace, remove and re-add the marketplace.",
],
}
});
fn check_installed(codex_dir: &Path) -> bool {
check_plugin_enabled(codex_dir, PLUGIN_KEY)
}
fn check_platform_plugin_installed(codex_dir: &Path) -> bool {
check_plugin_enabled(codex_dir, PLATFORM_PLUGIN_KEY)
}
/// Whether `config.toml` marks the given plugin key as enabled.
fn check_plugin_enabled(codex_dir: &Path, plugin_key: &str) -> bool {
let config_path = codex_dir.join("config.toml");
let Ok(contents) = fs::read_to_string(config_path) else {
return false;
};
let Ok(parsed) = contents.parse::<toml_edit::DocumentMut>() else {
return false;
};
parsed
.get("plugins")
.and_then(|plugins| plugins.get(plugin_key))
.and_then(|plugin| plugin.get("enabled"))
.and_then(|enabled| enabled.as_bool())
.unwrap_or(false)
}
/// Reads the latest cached Warp plugin version, if present.
fn installed_version(codex_dir: &Path) -> Option<String> {
installed_plugin_version(codex_dir, PLUGIN_NAME)
}
/// Reads the latest cached orchestration plugin version, if present.
fn installed_platform_plugin_version(codex_dir: &Path) -> Option<String> {
installed_plugin_version(codex_dir, PLATFORM_PLUGIN_NAME)
}
fn platform_plugin_version_is_current(codex_dir: &Path) -> bool {
installed_platform_plugin_version(codex_dir)
.map(|v| !compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
.unwrap_or(false)
}
/// Reads the latest cached version for `plugin_name` from
/// `plugins/cache/codex-warp/<plugin_name>/<version>/.codex-plugin/plugin.json`.
fn installed_plugin_version(codex_dir: &Path, plugin_name: &str) -> Option<String> {
let cache_dir = codex_dir
.join("plugins")
.join("cache")
.join(MARKETPLACE_NAME)
.join(plugin_name);
let entries = fs::read_dir(cache_dir).ok()?;
let mut latest: Option<String> = None;
for entry in entries.flatten() {
let manifest_path = entry.path().join(".codex-plugin").join("plugin.json");
let Some(version) = plugin_manifest_version(manifest_path) else {
continue;
};
if latest
.as_deref()
.map(|current| compare_versions(&version, current).is_gt())
.unwrap_or(true)
{
latest = Some(version);
}
}
latest
}
fn plugin_manifest_version(manifest_path: impl AsRef<Path>) -> Option<String> {
let contents = fs::read_to_string(manifest_path).ok()?;
let parsed = serde_json::from_str::<Value>(&contents).ok()?;
parsed
.get("version")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
fn plugin_needs_update(
codex_dir: &Path,
plugin_name: &str,
plugin_key: &str,
minimum_version: &str,
) -> bool {
if !check_plugin_enabled(codex_dir, plugin_key) {
return false;
}
match installed_plugin_version(codex_dir, plugin_name) {
Some(v) => compare_versions(&v, minimum_version).is_lt(),
// No version field means very old plugin.
None => true,
}
}
struct CodexWarpMarketplaceConfig {
source_type: Option<String>,
}
impl CodexWarpMarketplaceConfig {
fn is_git(&self) -> bool {
self.source_type.as_deref() == Some("git")
}
}
fn codex_warp_marketplace_config(codex_dir: &Path) -> Option<CodexWarpMarketplaceConfig> {
let config_path = codex_dir.join("config.toml");
let contents = fs::read_to_string(config_path).ok()?;
let parsed = contents.parse::<toml_edit::DocumentMut>().ok()?;
let marketplace = parsed.get("marketplaces")?.get(MARKETPLACE_NAME)?;
Some(CodexWarpMarketplaceConfig {
source_type: marketplace
.get("source_type")
.and_then(|source_type| source_type.as_str())
.map(str::to_owned),
})
}
/// Checks `CODEX_HOME` first, falls back to `~/.codex`.
fn codex_home_dir() -> io::Result<PathBuf> {
if let Ok(codex_home) = env::var(CODEX_HOME_ENV) {
if !codex_home.is_empty() {
return Ok(PathBuf::from(codex_home));
}
}
dirs::home_dir()
.map(|home| home.join(CODEX_CONFIG_DIR))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not determine home directory",
)
})
}
/// Creates the resolved Codex home directory if it does not yet exist.
/// The Codex CLI expects `CODEX_HOME` to exist before running plugin commands, we need
/// this for self-hosted direct backend workers.
fn ensure_codex_home_dir() -> io::Result<PathBuf> {
let dir = codex_home_dir()?;
fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -1,19 +1,483 @@
use std::fs;
use std::path::Path;
use super::CodexPluginManager;
use crate::features::FeatureFlag;
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
#[test]
fn can_auto_install_is_false() {
assert!(!CodexPluginManager.can_auto_install());
fn can_auto_install_is_true() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
assert!(CodexPluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn does_not_support_update() {
assert!(!CodexPluginManager.supports_update());
fn can_auto_install_is_false_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
assert!(!CodexPluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn install_instructions_has_steps() {
let instructions = CodexPluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
fn install_instructions_are_native_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let instructions = CodexPluginManager::new(None, None, None).install_instructions();
assert_eq!(instructions.title, "Enable Warp Notifications for Codex");
assert_eq!(
instructions.steps[1].command,
"[tui]\nnotification_condition = \"always\""
);
}
#[test]
fn supports_update() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
assert!(CodexPluginManager::new(None, None, None).supports_update());
}
#[test]
fn does_not_support_update_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
assert!(!CodexPluginManager::new(None, None, None).supports_update());
}
#[test]
fn minimum_version() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
assert_eq!(
CodexPluginManager::new(None, None, None).minimum_plugin_version(),
"0.4.0"
);
}
#[test]
fn minimum_version_is_zero_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
assert_eq!(
CodexPluginManager::new(None, None, None).minimum_plugin_version(),
"0.0.0"
);
}
#[test]
fn install_instructions_has_marketplace_and_plugin_add_steps() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let instructions = CodexPluginManager::new(None, None, None).install_instructions();
assert_eq!(
instructions.steps[0].command,
"codex plugin marketplace add warpdotdev/codex-warp"
);
assert_eq!(
instructions.steps[1].command,
"codex plugin add warp@codex-warp"
);
assert_eq!(instructions.steps.len(), 2);
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_marketplace_and_plugin_add_steps() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let instructions = CodexPluginManager::new(None, None, None).update_instructions();
assert_eq!(
instructions.steps[0].command,
"codex plugin marketplace upgrade codex-warp"
);
assert_eq!(
instructions.steps[1].command,
"codex plugin add warp@codex-warp"
);
assert_eq!(instructions.steps.len(), 2);
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_are_empty_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let instructions = CodexPluginManager::new(None, None, None).update_instructions();
assert!(instructions.steps.is_empty());
assert!(instructions.title.is_empty());
}
#[test]
fn installed_when_plugin_enabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
assert!(super::check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_disabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, false);
assert!(!super::check_installed(dir.path()));
}
#[test]
fn not_installed_when_only_marketplace_present() {
// Marketplace cloned but the plugin was never enabled.
let dir = tempfile::tempdir().unwrap();
write_marketplace_config(dir.path(), "git");
assert!(!super::check_installed(dir.path()));
}
#[test]
fn platform_plugin_installed_when_enabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
assert!(super::check_platform_plugin_installed(dir.path()));
}
#[test]
fn platform_plugin_not_installed_when_disabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, false);
assert!(!super::check_platform_plugin_installed(dir.path()));
}
#[test]
fn not_installed_when_config_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!super::check_installed(dir.path()));
}
#[test]
fn not_installed_when_config_invalid() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("config.toml"), "not toml").unwrap();
assert!(!super::check_installed(dir.path()));
}
#[test]
fn installed_version_reads_cache_manifest_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert_eq!(
super::installed_version(dir.path()).as_deref(),
Some("0.4.0")
);
}
#[test]
fn installed_platform_plugin_version_reads_cache_manifest_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert_eq!(
super::installed_platform_plugin_version(dir.path()).as_deref(),
Some("0.4.0")
);
}
#[test]
fn installed_version_picks_latest_cached() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.3.0");
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.5.0");
assert_eq!(
super::installed_version(dir.path()).as_deref(),
Some("0.5.0")
);
}
#[test]
fn installed_version_returns_none_when_cache_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(super::installed_version(dir.path()), None);
}
#[test]
fn installed_version_returns_none_when_cache_manifest_has_no_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest_without_version(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert_eq!(super::installed_version(dir.path()), None);
}
#[test]
fn platform_plugin_version_is_current_when_cache_current() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert!(super::platform_plugin_version_is_current(dir.path()));
}
#[test]
fn platform_plugin_version_is_not_current_when_cache_outdated() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
assert!(!super::platform_plugin_version_is_current(dir.path()));
}
#[test]
fn needs_update_true_when_enabled_and_version_outdated() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
assert!(super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_false_when_enabled_and_version_current() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_false_when_not_enabled() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_true_when_enabled_without_cached_version() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
assert!(super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn platform_plugin_needs_update_true_when_enabled_and_outdated() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
assert!(super::plugin_needs_update(
dir.path(),
super::PLATFORM_PLUGIN_NAME,
super::PLATFORM_PLUGIN_KEY,
super::MINIMUM_PLATFORM_PLUGIN_VERSION
));
}
#[test]
fn platform_plugin_needs_update_false_when_current() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLATFORM_PLUGIN_NAME,
super::PLATFORM_PLUGIN_KEY,
super::MINIMUM_PLATFORM_PLUGIN_VERSION
));
}
#[test]
#[serial_test::serial]
fn is_not_installed_via_trait_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_installed();
std::env::remove_var("CODEX_HOME");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn is_installed_via_trait_with_codex_home_env() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_installed();
std::env::remove_var("CODEX_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn is_platform_plugin_installed_via_trait_with_codex_home_env() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
std::env::remove_var("CODEX_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn is_platform_plugin_not_installed_via_trait_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
std::env::remove_var("CODEX_HOME");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn needs_update_via_trait_with_codex_home_env() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
std::env::remove_var("CODEX_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn does_not_need_update_via_trait_when_version_current() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
std::env::remove_var("CODEX_HOME");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn does_not_need_update_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
std::env::remove_var("CODEX_HOME");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn does_not_need_update_when_not_enabled() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
std::env::remove_var("CODEX_HOME");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn does_not_need_update_for_non_git_marketplace_override() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_marketplace_config(dir.path(), "directory");
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
let has_override = CodexPluginManager::new(None, None, None).has_local_marketplace_override();
std::env::remove_var("CODEX_HOME");
assert!(!result);
assert!(has_override);
}
fn write_plugin_config(dir: &Path, plugin_key: &str, enabled: bool) {
fs::write(
dir.join("config.toml"),
format!("[plugins.\"{plugin_key}\"]\nenabled = {enabled}\n"),
)
.unwrap();
}
fn write_marketplace_config(dir: &Path, source_type: &str) {
fs::write(
dir.join("config.toml"),
format!(
"[marketplaces.codex-warp]\nsource_type = \"{source_type}\"\nsource = \"/tmp/codex-warp\"\n"
),
)
.unwrap();
}
fn write_cache_manifest(dir: &Path, plugin_name: &str, version: &str) {
write_cache_manifest_json(
dir,
plugin_name,
version,
serde_json::json!({ "name": plugin_name, "version": version }),
);
}
fn write_cache_manifest_without_version(dir: &Path, plugin_name: &str, version_dir: &str) {
write_cache_manifest_json(
dir,
plugin_name,
version_dir,
serde_json::json!({ "name": plugin_name }),
);
}
fn write_cache_manifest_json(
dir: &Path,
plugin_name: &str,
version_dir: &str,
manifest: serde_json::Value,
) {
let manifest_dir = dir
.join("plugins")
.join("cache")
.join("codex-warp")
.join(plugin_name)
.join(version_dir)
.join(".codex-plugin");
fs::create_dir_all(&manifest_dir).unwrap();
fs::write(manifest_dir.join("plugin.json"), manifest.to_string()).unwrap();
}
@@ -1,8 +1,7 @@
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::{fs, io};
use async_trait::async_trait;
use serde_json::Value;
@@ -5,20 +5,19 @@ pub(crate) mod opencode;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::PathBuf;
use std::{fmt, io};
use async_trait::async_trait;
use claude::ClaudeCodePluginManager;
use codex::CodexPluginManager;
use gemini::GeminiPluginManager;
use opencode::OpenCodePluginManager;
use crate::features::FeatureFlag;
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
use crate::terminal::CLIAgent;
use claude::ClaudeCodePluginManager;
use codex::CodexPluginManager;
use gemini::GeminiPluginManager;
use opencode::OpenCodePluginManager;
/// Distinguishes whether the plugin instructions modal should show install or update steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,6 +51,7 @@ pub(crate) struct PluginInstructions {
/// Error returned when plugin installation fails.
/// Carries both a short user-facing message (for the toast) and a detailed
/// command log (for the log file the user can inspect).
#[derive(Debug)]
pub(crate) struct PluginInstallError {
/// Short description shown in the toast notification.
pub message: String,
@@ -65,6 +65,8 @@ impl fmt::Display for PluginInstallError {
}
}
impl std::error::Error for PluginInstallError {}
impl From<io::Error> for PluginInstallError {
fn from(err: io::Error) -> Self {
let msg = err.to_string();
@@ -160,6 +162,24 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
false
}
/// Whether this agent's Oz platform plugin is already installed.
/// Default returns `true` because most agents do not have a platform plugin.
fn is_platform_plugin_installed(&self) -> bool {
true
}
/// Whether this agent's Oz platform plugin is below the minimum required version.
/// Default returns `false` because most agents do not have a platform plugin.
fn platform_plugin_needs_update(&self) -> bool {
false
}
/// Whether the agent's plugin marketplace is currently overridden to a
/// local filesystem path. This is used by local test flows to avoid
/// clobbering a developer's marketplace override while still preserving
/// normal install/update behavior in staging and production.
fn has_local_marketplace_override(&self) -> bool {
false
}
/// Install the Warp notification plugin.
/// Default returns an error — only agents with `can_auto_install() == true` should override.
async fn install(&self) -> Result<(), PluginInstallError> {
@@ -207,6 +227,13 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
Ok(())
}
/// Update the Oz platform plugin for this CLI agent, if one exists.
/// Default reuses the install path because most agents do not have a
/// platform plugin or need distinct update behavior.
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
self.install_platform_plugin().await
}
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
@@ -242,7 +269,11 @@ pub(crate) fn plugin_manager_for_with_shell(
if FeatureFlag::CodexNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(CodexPluginManager))
Some(Box::new(CodexPluginManager::new(
shell_path,
shell_type,
path_env_var,
)))
}
CLIAgent::Gemini
if FeatureFlag::GeminiNotifications.is_enabled()
@@ -263,6 +294,10 @@ pub(crate) fn plugin_manager_for_with_shell(
| CLIAgent::Pi
| CLIAgent::Auggie
| CLIAgent::CursorCli
| CLIAgent::Hermes
| CLIAgent::Goose
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Unknown => None,
}
}
+35 -8
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::Local;
@@ -13,6 +12,7 @@ use super::{
build_selection_substring_prompt, CLIAgent, UBER_TEAM_UID,
};
use crate::ai::agent::{AgentReviewCommentBatch, DiffSetHunk};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentOrigin, LineDiffContent,
@@ -60,6 +60,10 @@ fn batch(comments: Vec<AttachedReviewComment>) -> AgentReviewCommentBatch {
}
}
fn local_path(path: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Local(path.into())
}
// ---------------------------------------------------------------------------
// build_review_prompt tests
// ---------------------------------------------------------------------------
@@ -70,7 +74,7 @@ fn test_build_review_prompt_current_line_is_1_indexed() {
let comment = make_comment(
"fix this",
AttachedReviewCommentTarget::Line {
absolute_file_path: PathBuf::from("/repo/src/main.rs"),
absolute_file_path: local_path("/repo/src/main.rs"),
line: EditorLineLocation::Current {
line_number: LineCount::from(0),
line_range: LineCount::from(0)..LineCount::from(1),
@@ -92,7 +96,7 @@ fn test_build_review_prompt_removed_line_is_1_indexed() {
let comment = make_comment(
"why was this deleted?",
AttachedReviewCommentTarget::Line {
absolute_file_path: PathBuf::from("/repo/old.rs"),
absolute_file_path: local_path("/repo/old.rs"),
line: EditorLineLocation::Removed {
line_number: LineCount::from(9),
line_range: LineCount::from(9)..LineCount::from(10),
@@ -114,7 +118,7 @@ fn test_build_review_prompt_collapsed_range_is_1_indexed_start() {
let comment = make_comment(
"check this hunk",
AttachedReviewCommentTarget::Line {
absolute_file_path: PathBuf::from("/repo/lib.rs"),
absolute_file_path: local_path("/repo/lib.rs"),
line: EditorLineLocation::Collapsed {
line_range: LineCount::from(4)..LineCount::from(10),
},
@@ -132,7 +136,7 @@ fn test_build_review_prompt_file_level_comment() {
let comment = make_comment(
"needs refactoring",
AttachedReviewCommentTarget::File {
absolute_file_path: PathBuf::from("/repo/src/utils.rs"),
absolute_file_path: local_path("/repo/src/utils.rs"),
},
false,
);
@@ -147,7 +151,7 @@ fn test_build_review_prompt_deleted_file_comment() {
let comment = make_comment(
"why remove this?",
AttachedReviewCommentTarget::File {
absolute_file_path: PathBuf::from("/repo/src/old.rs"),
absolute_file_path: local_path("/repo/src/old.rs"),
},
false,
);
@@ -193,7 +197,7 @@ fn test_build_review_prompt_multiple_comments() {
let c1 = make_comment(
"first",
AttachedReviewCommentTarget::Line {
absolute_file_path: PathBuf::from("/repo/a.rs"),
absolute_file_path: local_path("/repo/a.rs"),
line: EditorLineLocation::Current {
line_number: LineCount::from(4),
line_range: LineCount::from(4)..LineCount::from(5),
@@ -222,7 +226,7 @@ fn test_build_review_prompt_exports_internal_markdown_without_punctuation_escape
#[test]
fn test_build_diff_hunk_prompt_format() {
let prompt = build_diff_hunk_prompt(Path::new("/repo/src/main.rs"), 10, 20, 3, 2);
let prompt = build_diff_hunk_prompt("/repo/src/main.rs", 10, 20, 3, 2);
assert_eq!(
prompt,
"/repo/src/main.rs L10-L20 (+3 -2) -- run `git diff` to see the full context.",
@@ -258,6 +262,9 @@ fn test_detect_known_agents() {
("opencode", CLIAgent::OpenCode),
("copilot", CLIAgent::Copilot),
("agent", CLIAgent::CursorCli),
("goose", CLIAgent::Goose),
("vibe", CLIAgent::Vibe),
("agy", CLIAgent::Antigravity),
] {
assert_eq!(
CLIAgent::detect(command, None, None, ctx),
@@ -285,6 +292,26 @@ fn test_detect_with_arguments() {
});
}
#[test]
fn test_detect_vibe_acp_binary() {
// The mistral-vibe package ships a `vibe-acp` ACP-mode binary alongside
// the user-facing `vibe` TUI. Both must be detected as the same agent.
App::test((), |mut app| async move {
app.update(|ctx| {
assert_eq!(
CLIAgent::detect("vibe-acp", None, None, ctx),
Some(CLIAgent::Vibe),
);
assert_eq!(
CLIAgent::detect("vibe-acp --some-flag", None, None, ctx),
Some(CLIAgent::Vibe),
);
// Distinct binary names should not bleed into Vibe.
assert_eq!(CLIAgent::detect("vibe-other", None, None, ctx), None);
});
});
}
#[test]
fn test_detect_with_leading_whitespace() {
App::test((), |mut app| async move {
+5 -3
View File
@@ -1,9 +1,11 @@
use crate::terminal::model::ansi::color_index;
use crate::themes::theme::{AnsiColors, GalaxyTheme};
use galaxyui::color::ColorU;
use std::fmt;
use std::ops::{Index, IndexMut};
use galaxyui::color::ColorU;
use crate::terminal::model::ansi::color_index;
use crate::themes::theme::{AnsiColors, WarpTheme};
pub const COUNT: usize = 269;
/// Factor for automatic computation of dim colors used by terminal.
@@ -1,6 +1,7 @@
use lazy_static::lazy_static;
use std::collections::HashSet;
use lazy_static::lazy_static;
lazy_static! {
/// The set of command corrections that are NOT preferred over Next Command, in the case that both
/// features are enabled. Based on acceptance rates and rules themselves.
+8 -4
View File
@@ -22,7 +22,7 @@ use crate::send_telemetry_from_ctx;
use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent};
use crate::settings_view::create_discount_badge;
use crate::ui_components::blended_colors;
use crate::view_components::{Dropdown, ToastFlavor};
use crate::view_components::{Dropdown, DropdownAction, ToastFlavor};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
const DENOMINATION_DROPDOWN_WIDTH: f32 = MODAL_WIDTH - 2. * MODAL_PADDING;
@@ -194,11 +194,15 @@ impl EnableAutoReloadModalBody {
})),
Some(primary_text),
)
.with_on_select_action(Action::SelectDenomination(index).into())
.with_on_select_action(DropdownAction::select_action_and_close(
Action::SelectDenomination(index),
))
.into_item()
} else {
MenuItemFields::new(primary_text.clone())
.with_on_select_action(Action::SelectDenomination(index).into())
.with_on_select_action(DropdownAction::select_action_and_close(
Action::SelectDenomination(index),
))
.into_item()
}
})
@@ -363,7 +367,7 @@ impl View for EnableAutoReloadModalBody {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
SelectDenomination(usize),
Cancel,
+50 -45
View File
@@ -6,25 +6,22 @@ use std::sync::Arc;
use std::time::Duration;
use instant::Instant;
pub use remote_server::setup::RemoteServerSetupState;
use super::history::HistoryEntry;
use super::model::ansi::FinishUpdateValue;
use super::model::block::BlockId;
use super::model::session::{SessionId, SessionInfo};
use super::model::terminal_model::{BlockIndex, ExitReason};
use crate::server::ids::SyncId;
use crate::server::telemetry::ImageProtocol;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::block::SerializedBlock;
use crate::terminal::model::block::{BlockMetadata, SerializedBlock};
use crate::terminal::model::completions::ShellCompletion;
use crate::terminal::model::terminal_model::HandlerEvent;
use crate::terminal::shell::ShellType;
use crate::terminal::ClipboardType;
use crate::util::AsciiDebug;
use super::history::HistoryEntry;
use super::model::ansi::{FinishUpdateValue, WarpificationUnavailableReason};
use super::model::block::BlockId;
use super::model::session::{SessionId, SessionInfo};
use super::model::terminal_model::{BlockIndex, ExitReason, TmuxInstallationState};
pub use remote_server::setup::RemoteServerSetupState;
#[derive(Clone)]
/// Events sent to the main thread by the terminal model & event loop.
pub enum Event {
@@ -46,6 +43,13 @@ pub enum Event {
},
/// Sent when a new block is created.
BlockMetadataReceived(BlockMetadataReceivedEvent),
/// Sent when a block's working directory has been updated outside of the
/// normal precmd path (e.g. via an OSC 7 escape sequence). Subscribers
/// that only care about CWD changes should listen for this in addition to
/// `BlockMetadataReceived`; subscribers tied to precmd semantics (such as
/// the requested-command finish detector) should keep listening only to
/// `BlockMetadataReceived` so they preserve their once-per-block contract.
BlockWorkingDirectoryUpdated(BlockWorkingDirectoryUpdatedEvent),
/// Sent after a background block is started and added to the block list.
BackgroundBlockStarted,
ClipboardStore(ClipboardType, String),
@@ -77,18 +81,8 @@ pub enum Event {
SSHControlMasterError,
TerminalModeSwapped(TerminalMode),
ExecutedInBandCommand(ExecutedExecutorCommandEvent),
TmuxControlModeReady {
primary_pane: u32,
},
/// See comment above [crate::terminal::ModelEvent::DetectedEndOfSshLogin].
DetectedEndOfSshLogin(SshLoginStatus),
RemoteWarpificationIsUnavailable(WarpificationUnavailableReason),
SshTmuxInstaller(TmuxInstallationState),
TmuxInstallFailed {
line: String,
command: String,
},
InitSsh(InitSshEvent),
InitSubshell(InitSubshellEvent),
/// Emitted when the user's RC file has been executed in a subshell.
SourcedRcFileInSubshell(SourcedRcFileInSubshellEvent),
@@ -109,6 +103,7 @@ pub enum Event {
/// Users "Tag an agent in" when they ask the agent to take over a long running command
/// that was started outside of a conversation (and they tag the agent out when they take control back).
AgentTaggedInChanged {
block_id: BlockId,
is_tagged_in: bool,
},
Handler(HandlerEvent),
@@ -161,13 +156,6 @@ pub struct InitSubshellEvent {
pub struct SourcedRcFileInSubshellEvent {
pub shell_type: ShellType,
pub uname: Option<String>,
pub tmux: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct InitSshEvent {
pub shell_type: ShellType,
pub uname: Option<String>,
}
#[derive(Clone)]
@@ -282,6 +270,27 @@ pub struct BlockMetadataReceivedEvent {
pub is_done_bootstrapping: bool,
}
#[derive(Clone, Debug)]
/// A notification that an existing block's working directory has been updated
/// out-of-band (e.g. by an OSC 7 escape sequence) without a fresh precmd. The
/// payload mirrors `BlockMetadataReceivedEvent` so CWD-dependent listeners can
/// reuse the same handling, but listeners that rely on precmd semantics should
/// keep using `BlockMetadataReceivedEvent`.
///
/// Note: `is_for_in_band_command` here describes the block carrying the update,
/// while the similarly-spelled `is_after_in_band_command` on
/// `BlockMetadataReceivedEvent` describes the *previous* block. The semantics
/// differ because precmd fires after a block runs, while OSC 7 fires while the
/// block is alive.
pub struct BlockWorkingDirectoryUpdatedEvent {
pub block_metadata: BlockMetadata,
pub block_index: BlockIndex,
/// Whether the block carrying this update is for an in-band command.
pub is_for_in_band_command: bool,
/// Whether the session has fully completed the bootstrapping process.
pub is_done_bootstrapping: bool,
}
#[derive(Clone, Debug)]
/// Contents of a normal block that a user executed.
pub struct UserBlockCompleted {
@@ -320,7 +329,7 @@ pub struct UserBlockCompleted {
}
/// Emitted upon completion of an executor command that goes through the pty, such as the
/// InBandCommandExecutor or the TmuxCommandExecutor.
/// InBandCommandExecutor.
#[derive(Clone)]
pub struct ExecutedExecutorCommandEvent {
pub command_id: String,
@@ -411,6 +420,11 @@ impl Debug for Event {
"BlockStarted({:?}, Done bootstrapping: {:?})",
event.block_metadata, event.is_done_bootstrapping
),
Event::BlockWorkingDirectoryUpdated(event) => write!(
f,
"BlockWorkingDirectoryUpdated({:?}, Done bootstrapping: {:?})",
event.block_metadata, event.is_done_bootstrapping
),
Event::AfterBlockStarted { .. } => write!(f, "BlockExecutionStarted"),
Event::BackgroundBlockStarted => write!(f, "BackgroundBlockStarted"),
Event::VisibleBootstrapBlock => write!(f, "VisibleBootstrapBlock"),
@@ -425,21 +439,9 @@ impl Debug for Event {
Event::SSH(remote_shell) => write!(f, "SSH(remote shell: {remote_shell}"),
Event::SSHControlMasterError => write!(f, "SSH ControlMaster error"),
Event::TerminalModeSwapped(_) => write!(f, "Terminal mode swapped"),
Event::TmuxControlModeReady { primary_pane } => {
write!(f, "TmuxControlModeReady(primary_pane: {primary_pane})")
}
Event::DetectedEndOfSshLogin(check_type) => {
write!(f, "DetectedEndOfSshLogin: {check_type:?}")
}
Event::RemoteWarpificationIsUnavailable(_) => {
write!(f, "RemoteWarpificationIsUnavailable")
}
Event::SshTmuxInstaller(installer) => {
write!(f, "SshTmuxInstaller({installer:?})")
}
Event::TmuxInstallFailed { line, command } => {
write!(f, "TmuxInstallFailed(line: {line}, command: {command})")
}
Event::ExecutedInBandCommand(event) => write!(
f,
"Executed in-band command with ID {} and exit code {}",
@@ -451,14 +453,17 @@ impl Debug for Event {
Event::SourcedRcFileInSubshell(event) => {
write!(f, "SourcedRcFileInSubshell({event:?})")
}
Event::InitSsh(event) => {
write!(f, "InitSsh({event:?})")
}
Event::PromptUpdated => write!(f, "PromptUpdated"),
Event::HonorPS1OutOfSync => write!(f, "HonorPS1OutOfSync"),
Event::Typeahead => write!(f, "Typeahead"),
Event::AgentTaggedInChanged { is_tagged_in } => {
write!(f, "AgentTaggedInChanged(is_tagged_in: {is_tagged_in})")
Event::AgentTaggedInChanged {
block_id,
is_tagged_in,
} => {
write!(
f,
"AgentTaggedInChanged(block_id: {block_id:?}, is_tagged_in: {is_tagged_in})"
)
}
Event::Handler(handler_event) => write!(f, "Handler({handler_event:?}))"),
Event::RemoteServerReady { session_id } => {
+5 -5
View File
@@ -1,8 +1,9 @@
use std::sync::Arc;
use crate::terminal::event::Event as TerminalEvent;
use async_channel::Sender;
use crate::terminal::event::Event as TerminalEvent;
/// A wrapper struct that emits events which originate from the PTY event loop.
/// Instead of passing individual senders, we can pass through this struct
/// so that users have access to all of the senders in one nicely wrapped struct.
@@ -75,10 +76,9 @@ impl ChannelEventListener {
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-util"))]
mod testing;
#[cfg(any(test, feature = "test-util"))]
pub use testing::*;
use crate::terminal::model::terminal_model::HandlerEvent;
#[cfg(test)]
pub use testing::*;
+2 -1
View File
@@ -1,6 +1,7 @@
use async_channel::Sender;
use std::sync::Arc;
use async_channel::Sender;
use super::ChannelEventListener;
use crate::terminal::event::Event as TerminalEvent;
+2 -2
View File
@@ -2,6 +2,6 @@
pub mod model;
pub use model::{
BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions, FindableRichContentView,
RichContentMatchId, TerminalFindModel,
BlockFindRenderData, BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions,
FindableRichContentView, RichContentMatchId, TerminalFindModel,
};
+508 -62
View File
@@ -1,31 +1,176 @@
mod alt_screen;
pub mod async_find;
mod block_list;
#[allow(dead_code)]
mod rich_content;
#[cfg(any(test, feature = "integration_tests"))]
mod testing;
pub use block_list::{BlockGridMatch, BlockListFindRun, BlockListMatch};
pub use rich_content::{FindableRichContentView, RichContentMatchId};
use crate::terminal::block_list_viewport::InputMode;
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::ops::RangeInclusive;
use std::sync::Arc;
use alt_screen::{run_find_on_alt_screen, AltScreenFindRun};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle};
pub use async_find::{AsyncFindController, AsyncFindStatus};
use block_list::run_find_on_block_list;
pub use block_list::{BlockGridMatch, BlockListFindRun, BlockListMatch};
use parking_lot::FairMutex;
use rich_content::FindableRichContentHandle;
pub use rich_content::{FindableRichContentView, RichContentMatchId};
use settings::Setting as _;
use crate::{
settings::InputModeSettings,
terminal::model::{terminal_model::BlockIndex, TerminalModel},
view_components::find::{FindEvent, FindModel},
};
use crate::settings::InputModeSettings;
use crate::terminal::block_list_element::GridType;
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::model::grid::grid_handler::GridHandler;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::model::TerminalModel;
use crate::terminal::settings::TerminalSettings;
use crate::view_components::find::{FindDirection, FindEvent, FindModel};
use crate::view_components::find::FindDirection;
/// Pre-computed find data for rendering a single block.
///
/// This struct provides a unified interface for both sync (`BlockListFindRun`) and async
/// (`AsyncFindController`) find paths, allowing the rendering code to work with either.
///
/// Stores references to the underlying data source and provides methods to create iterators
/// on demand (since iterators can only be consumed once).
pub enum BlockFindRenderData<'a> {
/// Data from the synchronous find path.
Sync {
run: &'a BlockListFindRun,
block_index: BlockIndex,
},
/// Data from the asynchronous find path.
///
/// For the async path, we pre-compute and store converted matches since they use
/// absolute coordinates internally and need conversion to relative Points.
Async {
/// Pre-converted command grid matches (filtered for truncation).
command_matches: Vec<RangeInclusive<Point>>,
/// Pre-converted output grid matches (filtered for truncation).
output_matches: Vec<RangeInclusive<Point>>,
/// Focused range in command grid, if any.
focused_command_range: Option<RangeInclusive<Point>>,
/// Focused range in output grid, if any.
focused_output_range: Option<RangeInclusive<Point>>,
},
}
use block_list::run_find_on_block_list;
use rich_content::FindableRichContentHandle;
impl<'a> BlockFindRenderData<'a> {
/// Creates render data from the sync `BlockListFindRun`.
pub fn from_sync(run: &'a BlockListFindRun, block_index: BlockIndex) -> Self {
Self::Sync { run, block_index }
}
/// Creates render data from the async `AsyncFindController`.
///
/// This pre-converts matches from absolute to relative coordinates, filtering out
/// any matches that have been truncated from scrollback.
pub fn from_async(
controller: &'a AsyncFindController,
block_index: BlockIndex,
command_grid: Option<&GridHandler>,
output_grid: Option<&GridHandler>,
) -> Self {
// Convert command grid matches.
let command_matches = command_grid
.and_then(|grid| {
controller
.matches_for_block_grid(block_index, GridType::PromptAndCommand)
.map(|matches| {
matches
.iter()
.filter_map(|m| m.to_range(grid))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
// Convert output grid matches.
let output_matches = output_grid
.and_then(|grid| {
controller
.matches_for_block_grid(block_index, GridType::Output)
.map(|matches| {
matches
.iter()
.filter_map(|m| m.to_range(grid))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
// Get focused match ranges.
let focused_match = controller.focused_terminal_match();
let focused_command_range = focused_match
.as_ref()
.filter(|m| m.block_index == block_index && m.grid_type == GridType::PromptAndCommand)
.and_then(|m| command_grid.and_then(|grid| m.range.to_range(grid)));
let focused_output_range = focused_match
.as_ref()
.filter(|m| m.block_index == block_index && m.grid_type == GridType::Output)
.and_then(|m| output_grid.and_then(|grid| m.range.to_range(grid)));
Self::Async {
command_matches,
output_matches,
focused_command_range,
focused_output_range,
}
}
/// Returns an iterator over match ranges for the command grid.
pub fn command_grid_matches(
&self,
) -> Option<Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_>> {
match self {
Self::Sync { run, block_index } => {
Some(run.matches_for_block_grid(*block_index, GridType::PromptAndCommand))
}
Self::Async {
command_matches, ..
} => Some(Box::new(command_matches.iter())),
}
}
/// Returns an iterator over match ranges for the output grid.
pub fn output_grid_matches(
&self,
) -> Option<Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_>> {
match self {
Self::Sync { run, block_index } => {
Some(run.matches_for_block_grid(*block_index, GridType::Output))
}
Self::Async { output_matches, .. } => Some(Box::new(output_matches.iter())),
}
}
/// Returns the focused match range if it's in the specified grid.
pub fn focused_range_for_grid(&self, grid_type: GridType) -> Option<RangeInclusive<Point>> {
match self {
Self::Sync { run, block_index } => run.focused_match().and_then(|m| match m {
BlockListMatch::CommandBlock(grid_match)
if grid_match.block_index == *block_index
&& grid_match.grid_type == grid_type =>
{
Some(grid_match.range.clone())
}
_ => None,
}),
Self::Async {
focused_command_range,
focused_output_range,
..
} => match grid_type {
GridType::PromptAndCommand => focused_command_range.clone(),
GridType::Output => focused_output_range.clone(),
_ => None,
},
}
}
}
/// `TerminalView`-scoped model for the find bar.
pub struct TerminalFindModel {
@@ -36,11 +181,14 @@ pub struct TerminalFindModel {
/// The most recent find "run" on the alt screen, if any.
alt_screen_find_run: Option<AltScreenFindRun>,
/// The most recent find "run" on the block list, if any.
/// The most recent find "run" on the block list, if any (sync path).
block_list_find_run: Option<BlockListFindRun>,
/// `true` if the find bar is open.
is_find_bar_open: bool,
/// Controller for async find operations.
pub(crate) async_find_controller: Option<AsyncFindController>,
}
impl FindModel for TerminalFindModel {
@@ -49,6 +197,8 @@ impl FindModel for TerminalFindModel {
self.alt_screen_find_run
.as_ref()
.and_then(|run| run.focused_match_index())
} else if let Some(controller) = &self.async_find_controller {
controller.focused_match_index()
} else {
self.block_list_find_run
.as_ref()
@@ -62,6 +212,8 @@ impl FindModel for TerminalFindModel {
.as_ref()
.map(|run| run.matches().len())
.unwrap_or(0)
} else if let Some(controller) = &self.async_find_controller {
controller.match_count()
} else {
self.block_list_find_run
.as_ref()
@@ -77,24 +229,42 @@ impl FindModel for TerminalFindModel {
InputMode::PinnedToTop => FindDirection::Down,
}
}
fn is_scanning(&self) -> bool {
self.is_async_find_scanning()
}
}
impl TerminalFindModel {
pub fn new(terminal_model: Arc<FairMutex<TerminalModel>>) -> Self {
pub fn new(terminal_model: Arc<FairMutex<TerminalModel>>, ctx: &AppContext) -> Self {
let async_find_controller = if TerminalSettings::as_ref(ctx).is_async_find_enabled() {
Some(AsyncFindController::new(terminal_model.clone()))
} else {
None
};
Self {
terminal_model,
rich_content_views: HashMap::new(),
alt_screen_find_run: None,
block_list_find_run: None,
is_find_bar_open: false,
async_find_controller,
}
}
pub fn register_findable_rich_content_view<T: FindableRichContentView>(
&mut self,
view_handle: ViewHandle<T>,
) {
self.rich_content_views
.insert(view_handle.id(), Box::new(view_handle));
let view_id = view_handle.id();
let boxed_handle: Box<dyn FindableRichContentHandle> = Box::new(view_handle.clone());
// Register with async find controller if enabled.
if let Some(controller) = &mut self.async_find_controller {
controller.register_rich_content_view(view_id, Box::new(view_handle));
}
self.rich_content_views.insert(view_id, boxed_handle);
}
/// Returns `true` if the find bar is currently open.
@@ -117,10 +287,115 @@ impl TerminalFindModel {
self.block_list_find_run.as_ref()
}
/// Returns the currently focused match as a `BlockListMatch`.
///
/// This works for both sync and async find paths.
pub(crate) fn focused_block_list_match(&self) -> Option<BlockListMatch> {
let model = self.terminal_model.lock();
if model.is_alt_screen_active() {
// Alt screen doesn't use BlockListMatch.
return None;
}
if let Some(controller) = &self.async_find_controller {
// Async path: the focused match is either a terminal match or an
// AI match (or neither). Try each in turn and synthesize the
// corresponding `BlockListMatch` variant so consumers don't need
// to know which path produced the focus.
if let Some(async_match) = controller.focused_terminal_match() {
let block = model.block_list().block_at(async_match.block_index)?;
let grid = match async_match.grid_type {
GridType::PromptAndCommand => block.prompt_and_command_grid().grid_handler(),
GridType::Output => block.output_grid().grid_handler(),
_ => return None,
};
let range = async_match.range.to_range(grid)?;
return Some(BlockListMatch::CommandBlock(BlockGridMatch {
block_index: async_match.block_index,
grid_type: async_match.grid_type,
range,
is_filtered: false,
}));
}
if let Some(ai_match) = controller.focused_ai_match() {
return Some(BlockListMatch::RichContent {
match_id: ai_match.match_id,
view_id: ai_match.view_id,
index: ai_match.total_index,
});
}
None
} else {
// Sync path: get from block_list_find_run.
self.block_list_find_run
.as_ref()
.and_then(|run| run.focused_match())
.cloned()
}
}
/// Returns the focused rich content (AI) match id, if any.
///
/// This works for both sync and async find paths and is used by AI block
/// rendering to apply the focused-match highlight color.
pub(crate) fn focused_rich_content_match_id(&self) -> Option<RichContentMatchId> {
if self.terminal_model.lock().is_alt_screen_active() {
return None;
}
if let Some(controller) = &self.async_find_controller {
controller.focused_ai_match().map(|m| m.match_id)
} else {
self.block_list_find_run
.as_ref()
.and_then(|run| run.focused_match())
.and_then(|m| match m {
BlockListMatch::RichContent { match_id, .. } => Some(*match_id),
_ => None,
})
}
}
/// Returns find render data for a specific block, if find is active.
///
/// This works for both sync and async find paths.
///
/// Note: This method does NOT check if alt screen is active. It is intended
/// for use during blocklist rendering where the caller has already determined
/// that we are not in alt screen mode. Callers who need alt screen checking
/// should do so before calling this method.
///
/// For async find, the grid handlers are needed to convert from absolute to
/// relative coordinates and filter truncated matches.
pub(crate) fn find_render_data_for_block(
&self,
block_index: BlockIndex,
command_grid: Option<&GridHandler>,
output_grid: Option<&GridHandler>,
) -> Option<BlockFindRenderData<'_>> {
if let Some(controller) = &self.async_find_controller {
if !controller.has_active_find() {
return None;
}
Some(BlockFindRenderData::from_async(
controller,
block_index,
command_grid,
output_grid,
))
} else {
self.block_list_find_run
.as_ref()
.map(|run| BlockFindRenderData::from_sync(run, block_index))
}
}
/// Returns `FindOptions` applied to the active find run, if any.
pub fn active_find_options(&self) -> Option<&FindOptions> {
if self.terminal_model.lock().is_alt_screen_active() {
self.alt_screen_find_run.as_ref().map(|run| run.options())
} else if let Some(controller) = &self.async_find_controller {
controller.find_options()
} else {
self.block_list_find_run.as_ref().map(|run| run.options())
}
@@ -134,22 +409,35 @@ impl TerminalFindModel {
options,
self.terminal_model.lock().alt_screen(),
));
} else {
let _ = self.block_list_find_run.take();
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
self.block_list_find_run = Some(run_find_on_block_list(
options,
self.terminal_model.lock().block_list(),
&self.rich_content_views,
block_sort_direction,
ctx,
));
ctx.emit(FindEvent::RanFind);
return;
}
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
// Use async find if the feature flag is enabled.
if let Some(controller) = &mut self.async_find_controller {
log::trace!(
"[async_find] Starting async find with query: {:?}",
options.query
);
controller.start_find(&options, block_sort_direction, ctx);
ctx.emit(FindEvent::RanFind);
return;
}
// Synchronous path.
let _ = self.block_list_find_run.take();
self.block_list_find_run = Some(run_find_on_block_list(
options,
self.terminal_model.lock().block_list(),
&self.rich_content_views,
block_sort_direction,
ctx,
));
ctx.emit(FindEvent::RanFind);
}
@@ -162,37 +450,82 @@ impl TerminalFindModel {
Some(old_find_state.rerun(self.terminal_model.lock().alt_screen()));
ctx.emit(FindEvent::RanFind);
}
} else {
// Find the last block index. This is the only block whose state may change.
let last_block_index = self
.terminal_model
.lock()
.block_list()
.last_non_hidden_block_by_index()
.unwrap_or_default();
return;
}
// Call find on the the last block's command and output grids.
// If the block is a new finished block, the matches are inserted at a new key, the block's index in the blocklist.
// If the block is an active, running block, its matches are overwritten in the terminal's block_matches.
if let Some(block) = self
.terminal_model
.lock()
.block_list()
.block_at(last_block_index)
{
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
// Handle async find path.
if let Some(controller) = &self.async_find_controller {
if !controller.has_active_find() {
return;
}
if let Some(old_find_run) = self.block_list_find_run.take() {
self.block_list_find_run = Some(old_find_run.rerun_on_block(
block,
last_block_index,
block_sort_direction,
));
ctx.emit(FindEvent::RanFind);
}
// Get the active block index and dirty range info.
// We use active_block_index() (not last_non_hidden_block_by_index) because
// the active block is where output is being written, even if it's still
// "empty" and would be filtered out by the default BlockFilter.
let mut model = self.terminal_model.lock();
let active_block_index = model.block_list().active_block_index();
// Consume dirty ranges from both grids. We need mutable access
// because take_find_dirty_rows_range is destructive.
let active_block = model.block_list_mut().active_block_mut();
let output_dirty_info =
active_block
.grid_of_type_mut(GridType::Output)
.and_then(|grid| {
let dirty = grid.grid_handler_mut().take_find_dirty_rows_range()?;
let truncated = grid.grid_handler().num_lines_truncated();
Some((dirty, GridType::Output, truncated))
});
let command_dirty_info = active_block
.grid_of_type_mut(GridType::PromptAndCommand)
.and_then(|grid| {
let dirty = grid.grid_handler_mut().take_find_dirty_rows_range()?;
let truncated = grid.grid_handler().num_lines_truncated();
Some((dirty, GridType::PromptAndCommand, truncated))
});
// Drop the model lock before emitting events.
drop(model);
self.invalidate_async_find_block(active_block_index, output_dirty_info, ctx);
if let Some(info) = command_dirty_info {
self.invalidate_async_find_block(active_block_index, Some(info), ctx);
}
return;
}
// Sync find path.
// Find the last block index. This is the only block whose state may change.
let last_block_index = self
.terminal_model
.lock()
.block_list()
.last_non_hidden_block_by_index()
.unwrap_or_default();
// Call find on the the last block's command and output grids.
// If the block is a new finished block, the matches are inserted at a new key, the block's index in the blocklist.
// If the block is an active, running block, its matches are overwritten in the terminal's block_matches.
if let Some(block) = self
.terminal_model
.lock()
.block_list()
.block_at(last_block_index)
{
let block_sort_direction = InputModeSettings::handle(ctx)
.as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
if let Some(old_find_run) = self.block_list_find_run.take() {
self.block_list_find_run = Some(old_find_run.rerun_on_block(
block,
last_block_index,
block_sort_direction,
));
ctx.emit(FindEvent::RanFind);
}
}
}
@@ -210,6 +543,8 @@ impl TerminalFindModel {
if let Some(alt_screen_find_run) = self.alt_screen_find_run.as_mut() {
alt_screen_find_run.focus_next_match(find_direction);
}
} else if let Some(controller) = &mut self.async_find_controller {
controller.focus_next_match(find_direction);
} else if let Some(block_list_find_run) = self.block_list_find_run.as_mut() {
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
@@ -221,12 +556,31 @@ impl TerminalFindModel {
ctx.emit(FindEvent::UpdatedFocusedMatch);
}
/// Notifies every registered rich-content child view (e.g. AI blocks) to
/// drop its cached find state and repaint, **without** touching the active
/// find run's options/config.
///
/// Callers that just need stale highlights to disappear (e.g.
/// `close_find_bar`) must use this rather than [`Self::clear_matches`].
/// On the async path, `clear_matches` routes through
/// `AsyncFindController::clear_results`, which also drops
/// `current_find_options` — losing the query that `open_find_bar` later
/// reads back via [`Self::active_find_options`] to restore the previous
/// search.
pub fn clear_rich_content_matches(&self, ctx: &mut ModelContext<Self>) {
for view in self.rich_content_views.values() {
view.clear_matches(ctx);
}
}
/// Clears matches in the active find run, if any.
pub fn clear_matches(&mut self, ctx: &mut ModelContext<Self>) {
if self.terminal_model.lock().is_alt_screen_active() {
if let Some(run) = self.alt_screen_find_run.take() {
self.alt_screen_find_run = Some(run.cleared());
}
} else if let Some(controller) = &mut self.async_find_controller {
controller.clear_results(ctx);
} else if let Some(run) = self.block_list_find_run.take() {
for (_, rich_content_view) in self.rich_content_views.iter() {
rich_content_view.clear_matches(ctx);
@@ -247,6 +601,11 @@ impl TerminalFindModel {
block_index: BlockIndex,
ctx: &mut ModelContext<Self>,
) {
// Async find handles block invalidation differently via invalidate_block().
if self.async_find_controller.is_some() {
return;
}
let terminal_model = self.terminal_model.lock();
if let (Some(block_list_find_run), Some(filtered_block)) = (
self.block_list_find_run.as_mut(),
@@ -265,6 +624,93 @@ impl TerminalFindModel {
ctx.emit(FindEvent::RanFind);
}
}
/// Returns true if an async find operation is currently scanning.
pub fn is_async_find_scanning(&self) -> bool {
self.async_find_controller
.as_ref()
.map(|c| c.is_scanning())
.unwrap_or(false)
}
/// Invalidates results for a specific block in async find.
///
/// This should be called when a block's content changes.
///
/// # Arguments
/// * `block_index` - The index of the block that changed.
/// * `dirty_info` - If provided, a `(row_range, grid_type, num_lines_truncated)`
/// tuple describing the dirty region. If `None`, a full block rescan is enqueued.
/// * `ctx` - The model context.
pub fn invalidate_async_find_block(
&mut self,
block_index: BlockIndex,
dirty_info: Option<(RangeInclusive<usize>, GridType, u64)>,
ctx: &mut ModelContext<Self>,
) {
if let Some(controller) = self.async_find_controller.as_mut() {
controller.invalidate_block(block_index, dirty_info);
} else {
return;
}
ctx.emit(FindEvent::RanFind);
}
/// Notifies async find that a block has completed.
///
/// This should be called when a command finishes, so that the completed block
/// (which now has its final output) gets scanned for matches if find is active.
/// Uses the dirty range accumulated during execution for incremental scanning.
pub fn notify_block_completed(
&mut self,
block_index: BlockIndex,
ctx: &mut ModelContext<Self>,
) {
if self.async_find_controller.is_none() {
return;
}
// Check if there's an active find before acquiring the lock.
let has_active_find = self
.async_find_controller
.as_ref()
.map(|c| c.has_active_find())
.unwrap_or(false);
if !has_active_find {
return;
}
log::trace!(
"[async_find] notify_block_completed: block_index={:?}",
block_index
);
// Get the dirty range from the completed block's output grid.
// We need mutable access to consume the dirty range.
let (dirty_range, num_lines_truncated) = {
let mut model = self.terminal_model.lock();
model
.block_list_mut()
.block_at_mut(block_index)
.and_then(|block| block.grid_of_type_mut(GridType::Output))
.map(|output_grid| {
let dirty_range = output_grid.grid_handler_mut().take_find_dirty_rows_range();
let num_lines_truncated = output_grid.grid_handler().num_lines_truncated();
(dirty_range, num_lines_truncated)
})
.unwrap_or((None, 0))
};
// Use invalidate_async_find_block which handles the dirty range properly.
let dirty_info = dirty_range.map(|range| (range, GridType::Output, num_lines_truncated));
self.invalidate_async_find_block(block_index, dirty_info, ctx);
}
/// Returns the async find controller, if enabled.
pub fn async_find_controller(&self) -> Option<&AsyncFindController> {
self.async_find_controller.as_ref()
}
}
impl Entity for TerminalFindModel {
+5 -10
View File
@@ -1,16 +1,11 @@
//! This module implements terminal find functionality for the alt screen.
use std::ops::RangeInclusive;
use crate::{
terminal::model::{
alt_screen::AltScreen,
find::{FindConfig, RegexDFAs},
index::Point,
},
view_components::find::FindDirection,
};
use super::FindOptions;
use crate::terminal::model::alt_screen::AltScreen;
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::index::Point;
use crate::view_components::find::FindDirection;
/// Runs a find operation on the blocklist using the given `options` and returns an
/// `AltScreenFindRun` with the results.
@@ -170,5 +165,5 @@ impl AltScreenFindRun {
}
#[cfg(test)]
#[path = "alt_screen_test.rs"]
#[path = "alt_screen_tests.rs"]
mod tests;
@@ -1,8 +1,7 @@
use crate::terminal::{
find::model::{alt_screen::run_find_on_alt_screen, FindOptions},
model::index::Point,
TerminalModel,
};
use crate::terminal::find::model::alt_screen::run_find_on_alt_screen;
use crate::terminal::find::model::FindOptions;
use crate::terminal::model::index::Point;
use crate::terminal::TerminalModel;
#[test]
fn test_run_find_on_alt_screen() {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
//! Background task for async find operations.
//!
//! This module contains the logic that runs on a background thread to scan
//! terminal blocks for matches without blocking the main thread. The task
//! pulls work items from a shared [`FindWorkQueue`] and streams results
//! back via an `async_channel`.
use std::ops::RangeInclusive;
use std::sync::Arc;
use futures_lite::future::yield_now;
use instant::Instant;
use parking_lot::FairMutex;
use warp_terminal::model::grid::Dimensions;
use warpui::{Entity, ModelContext};
use super::work_queue::{FindWorkItem, FindWorkQueue};
use super::{AbsoluteMatch, AsyncFindConfig, FindTaskMessage};
use crate::terminal::block_list_element::GridType;
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::grid::grid_handler::GridHandler;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::model::TerminalModel;
/// Maximum time (in milliseconds) to hold the terminal model lock during a find chunk.
const MAX_LOCK_DURATION_MS: u64 = 5;
/// Number of rows to scan per chunk within a terminal block.
const ROWS_PER_CHUNK: usize = 1000;
/// Spawns a background find task that pulls work from the given queue.
///
/// Returns a handle that can be used to abort the spawned future.
pub fn spawn_find_task<E: Entity>(
config: AsyncFindConfig,
terminal_model: Arc<FairMutex<TerminalModel>>,
queue: FindWorkQueue,
result_tx: async_channel::Sender<FindTaskMessage>,
ctx: &mut ModelContext<E>,
) -> warpui::r#async::SpawnedFutureHandle {
ctx.spawn(
async move {
run_find_task_loop(config, terminal_model, queue, result_tx).await;
},
|_me, (), _ctx| {
// Task completed — nothing to do here as results are sent via channel.
},
)
}
/// Runs the main find task loop, pulling work items from the queue.
async fn run_find_task_loop(
config: AsyncFindConfig,
terminal_model: Arc<FairMutex<TerminalModel>>,
queue: FindWorkQueue,
result_tx: async_channel::Sender<FindTaskMessage>,
) {
// Build RegexDFAs from config.
let Ok(dfas) = RegexDFAs::new_with_config(
config.query.as_str(),
FindConfig {
is_regex_enabled: config.is_regex_enabled,
is_case_sensitive: config.is_case_sensitive,
},
) else {
// Invalid regex — signal completion with no matches.
let _ = result_tx.send(FindTaskMessage::Done).await;
return;
};
while let Ok((item, queue_drained)) = queue.pop().await {
match item {
FindWorkItem::FullBlock { block_index } => {
scan_terminal_block_chunked(
block_index,
&terminal_model,
&dfas,
&result_tx,
config.block_sort_direction,
)
.await;
}
FindWorkItem::DirtyRange {
block_index,
grid_type,
row_range,
num_lines_truncated,
} => {
scan_grid_chunked(
block_index,
grid_type,
*row_range.start(),
Some(*row_range.end() + 1),
ScanResultMode::DirtyRange {
num_lines_truncated,
},
&terminal_model,
&dfas,
&result_tx,
)
.await;
}
FindWorkItem::AIBlock {
view_id,
total_index,
} => {
// Forward to main thread for execution.
let _ = result_tx
.send(FindTaskMessage::ScanAIBlock {
view_id,
total_index,
})
.await;
}
}
// The emptiness flag is checked atomically with the pop inside
// the queue lock, avoiding the TOCTOU race of a separate
// `is_empty()` call.
if queue_drained {
let _ = result_tx.send(FindTaskMessage::Done).await;
}
}
}
/// Scans a terminal block in chunks, streaming results back to the main thread.
async fn scan_terminal_block_chunked(
block_index: BlockIndex,
terminal_model: &Arc<FairMutex<TerminalModel>>,
dfas: &RegexDFAs,
result_tx: &async_channel::Sender<FindTaskMessage>,
block_sort_direction: crate::terminal::model::terminal_model::BlockSortDirection,
) {
// Determine grid order based on sort direction.
let grid_order = match block_sort_direction {
crate::terminal::model::terminal_model::BlockSortDirection::MostRecentFirst => {
&[GridType::PromptAndCommand, GridType::Output]
}
crate::terminal::model::terminal_model::BlockSortDirection::MostRecentLast => {
&[GridType::Output, GridType::PromptAndCommand]
}
};
for &grid_type in grid_order {
scan_grid_chunked(
block_index,
grid_type,
0,
None,
ScanResultMode::FullBlock,
terminal_model,
dfas,
result_tx,
)
.await;
}
}
/// Controls how each chunk's matches are sent to the main thread.
enum ScanResultMode {
/// Send [`FindTaskMessage::BlockGridMatches`] per chunk. Empty chunks are
/// skipped (no message sent).
FullBlock,
/// Send [`FindTaskMessage::DirtyRangeMatches`] per chunk, converting the
/// scanned row range to absolute coordinates using the provided truncation
/// offset. Messages are always sent, even for empty chunks, so that old
/// matches in the sub-range are cleared.
DirtyRange { num_lines_truncated: u64 },
}
/// Scans a range of rows within a single grid in chunks, releasing the
/// terminal model lock between chunks to avoid blocking the main thread.
///
/// Both full-block scanning and dirty-range scanning delegate to this
/// function; the [`ScanResultMode`] determines the message type sent per
/// chunk.
///
/// # Arguments
/// * `start_row` — First row to scan (inclusive).
/// * `end_row` — Upper bound on rows to scan (exclusive). `None` scans to
/// the end of the grid.
/// * `mode` — Determines the message type sent per chunk.
#[allow(clippy::too_many_arguments)]
async fn scan_grid_chunked(
block_index: BlockIndex,
grid_type: GridType,
start_row: usize,
end_row: Option<usize>,
mode: ScanResultMode,
terminal_model: &Arc<FairMutex<TerminalModel>>,
dfas: &RegexDFAs,
result_tx: &async_channel::Sender<FindTaskMessage>,
) {
let mut current_row = start_row;
loop {
let chunk_result = {
let lock_start = Instant::now();
let model = terminal_model.lock();
let Some(block) = model.block_list().block_at(block_index) else {
// Block no longer exists.
return;
};
let grid = match grid_type {
GridType::Output => block.output_grid(),
GridType::PromptAndCommand => block.prompt_and_command_grid(),
_ => return,
};
let grid_handler = grid.grid_handler();
let total_rows = grid_handler.total_rows();
let effective_end = end_row.unwrap_or(total_rows).min(total_rows);
if current_row >= effective_end {
return;
}
let chunk_end = (current_row + ROWS_PER_CHUNK).min(effective_end);
let point_matches = scan_grid_range(grid_handler, dfas, current_row, chunk_end);
let matches: Vec<AbsoluteMatch> = point_matches
.iter()
.map(|range| AbsoluteMatch::from_range(range, grid_handler))
.collect();
let elapsed = lock_start.elapsed();
(matches, chunk_end, effective_end, elapsed)
};
let (mut matches, chunk_end, effective_end, elapsed) = chunk_result;
// find_in_range returns matches in descending order; reverse to ascending.
matches.reverse();
// Send chunk results based on mode.
match &mode {
ScanResultMode::FullBlock => {
if !matches.is_empty() {
let _ = result_tx
.send(FindTaskMessage::BlockGridMatches {
block_index,
grid_type,
matches,
})
.await;
}
}
ScanResultMode::DirtyRange {
num_lines_truncated,
} => {
let absolute_start = current_row as u64 + num_lines_truncated;
let absolute_end = (chunk_end - 1) as u64 + num_lines_truncated;
let _ = result_tx
.send(FindTaskMessage::DirtyRangeMatches {
block_index,
grid_type,
dirty_range: absolute_start..=absolute_end,
matches,
})
.await;
}
}
if chunk_end >= effective_end {
break;
}
current_row = chunk_end;
// Yield to let other tasks run if we held the lock for a while.
if elapsed.as_millis() > MAX_LOCK_DURATION_MS as u128 / 2 {
yield_now().await;
}
}
}
/// Scans a range of rows in a grid for matches.
fn scan_grid_range(
grid: &GridHandler,
dfas: &RegexDFAs,
start_row: usize,
end_row: usize,
) -> Vec<RangeInclusive<Point>> {
let columns = Dimensions::columns(grid);
let start_point = Point::new(start_row, 0);
let end_point = Point::new(end_row.saturating_sub(1), columns.saturating_sub(1));
grid.find_in_range(dfas, start_point, end_point).collect()
}
@@ -0,0 +1,204 @@
//! Work queue for the async find background task.
//!
//! The [`FindWorkQueue`] is shared between the main thread (which enqueues work)
//! and the background task (which pulls items via [`FindWorkQueue::pop`]).
//! Internally it uses an [`event_listener::Event`] to efficiently wake the
//! background task when new work is available.
use std::collections::VecDeque;
use std::ops::RangeInclusive;
use std::sync::{Arc, Mutex};
use event_listener::Event;
use warpui::EntityId;
use super::BlockInfo;
use crate::terminal::block_list_element::GridType;
use crate::terminal::model::blocks::TotalIndex;
use crate::terminal::model::terminal_model::BlockIndex;
/// A unit of work for the background find task.
#[derive(Debug, Clone)]
pub enum FindWorkItem {
/// Scan an entire terminal block.
FullBlock { block_index: BlockIndex },
/// Scan a dirty range within a specific grid of a terminal block.
DirtyRange {
block_index: BlockIndex,
grid_type: GridType,
row_range: RangeInclusive<usize>,
num_lines_truncated: u64,
},
/// Request scanning of an AI block on the main thread.
AIBlock {
view_id: EntityId,
total_index: TotalIndex,
},
}
/// Error returned by [`FindWorkQueue::pop`] when the queue has been closed.
#[derive(Debug)]
pub struct QueueClosed;
struct FindWorkQueueInner {
items: VecDeque<FindWorkItem>,
closed: bool,
}
/// A shared work queue for async find operations.
///
/// The controller enqueues work items from the main thread, and the background
/// task pulls them via the async [`pop`](FindWorkQueue::pop) method. When the
/// queue is empty, `pop` blocks until new work arrives or the queue is closed.
#[derive(Clone)]
pub struct FindWorkQueue {
inner: Arc<Mutex<FindWorkQueueInner>>,
event: Arc<Event>,
}
impl FindWorkQueue {
/// Creates a new empty work queue.
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(FindWorkQueueInner {
items: VecDeque::new(),
closed: false,
})),
event: Arc::new(Event::new()),
}
}
/// Populates the queue with initial scan items from a block info list.
///
/// Terminal blocks become [`FindWorkItem::ScanFullBlock`] items and rich
/// content blocks become [`FindWorkItem::ScanAIBlock`] items. Items are
/// pushed in the order provided (typically newest-first from
/// [`collect_block_info`](super::collect_block_info)).
pub fn enqueue_full_scan(&self, blocks: &[BlockInfo]) {
let mut inner = self.inner.lock().unwrap();
for block in blocks {
let item = match block {
BlockInfo::Terminal { block_index, .. } => FindWorkItem::FullBlock {
block_index: *block_index,
},
BlockInfo::RichContent {
view_id,
total_index,
} => FindWorkItem::AIBlock {
view_id: *view_id,
total_index: *total_index,
},
};
inner.items.push_back(item);
}
drop(inner);
// Wake the background task if it is waiting.
self.event.notify(1);
}
/// Enqueues work for a block that has been invalidated.
///
/// If a [`FindWorkItem::ScanFullBlock`] for this block is already pending in
/// the queue, this is a no-op: the pending scan will pick up the latest
/// content. Otherwise, the appropriate work item is pushed to the **front**
/// of the queue so it is processed before remaining initial-scan items.
pub fn invalidate_block(
&self,
block_index: BlockIndex,
dirty_range: Option<(RangeInclusive<usize>, GridType, u64)>,
) {
let mut inner = self.inner.lock().unwrap();
// If there is already a pending full scan for this block, do nothing.
let has_pending_full_scan = inner.items.iter().any(|item| {
matches!(item, FindWorkItem::FullBlock { block_index: idx } if *idx == block_index)
});
if has_pending_full_scan {
return;
}
// Enqueue the appropriate item at the front (high priority).
let item = match dirty_range {
Some((row_range, grid_type, num_lines_truncated)) => FindWorkItem::DirtyRange {
block_index,
grid_type,
row_range,
num_lines_truncated,
},
None => FindWorkItem::FullBlock { block_index },
};
inner.items.push_front(item);
drop(inner);
self.event.notify(1);
}
/// Pulls the next work item from the queue.
///
/// If the queue is empty, the returned future blocks until an item is
/// enqueued or the queue is closed. Returns `Err(QueueClosed)` when the
/// queue has been closed and no items remain.
///
/// The returned `bool` indicates whether the queue was empty immediately
/// after the pop (checked atomically within the same lock scope).
pub async fn pop(&self) -> Result<(FindWorkItem, bool), QueueClosed> {
loop {
// Check for an available item or closed state.
{
let mut inner = self.inner.lock().unwrap();
if let Some(item) = inner.items.pop_front() {
let is_empty = inner.items.is_empty();
return Ok((item, is_empty));
}
if inner.closed {
return Err(QueueClosed);
}
}
// Queue is empty and not closed. Register a listener before
// re-checking to avoid a race between the check and the listen.
let listener = self.event.listen();
// Re-check after registering the listener.
{
let mut inner = self.inner.lock().unwrap();
if let Some(item) = inner.items.pop_front() {
let is_empty = inner.items.is_empty();
return Ok((item, is_empty));
}
if inner.closed {
return Err(QueueClosed);
}
}
// Wait for a notification.
listener.await;
}
}
/// Closes the queue, waking any blocked [`pop`](FindWorkQueue::pop) call.
///
/// After closing, `pop` will drain remaining items and then return
/// `Err(QueueClosed)`.
pub fn close(&self) {
let mut inner = self.inner.lock().unwrap();
inner.closed = true;
drop(inner);
self.event.notify(usize::MAX);
}
/// Removes all pending items from the queue.
pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap();
inner.items.clear();
}
/// Returns `true` if the queue has no pending items.
pub fn is_empty(&self) -> bool {
self.inner.lock().unwrap().items.is_empty()
}
/// Returns the number of pending items.
pub fn len(&self) -> usize {
self.inner.lock().unwrap().items.len()
}
}
@@ -0,0 +1,948 @@
//! Tests for async find functionality.
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{
is_query_refinement, AbsoluteMatch, AsyncFindConfig, AsyncFindController, AsyncFindStatus,
BlockFindResults, FindTaskMessage,
};
use crate::terminal::block_list_element::GridType;
use crate::terminal::find::model::block_list::run_find_on_block_list;
use crate::terminal::find::model::{FindOptions, TerminalFindModel};
use crate::terminal::find::{BlockListMatch, RichContentMatchId};
use crate::terminal::model::blocks::TotalIndex;
use crate::terminal::model::grid::grid_handler::AbsolutePoint;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::model::TerminalModel;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::view_components::find::FindDirection;
/// Helper to create an AbsoluteMatch at a given row with default column span.
fn make_match(row: u64) -> AbsoluteMatch {
AbsoluteMatch {
start: AbsolutePoint { row, col: 0 },
end: AbsolutePoint { row, col: 5 },
}
}
/// Helper to create an AbsoluteMatch at a given row and column range.
fn make_match_at(row: u64, start_col: usize, end_col: usize) -> AbsoluteMatch {
AbsoluteMatch {
start: AbsolutePoint {
row,
col: start_col,
},
end: AbsolutePoint { row, col: end_col },
}
}
#[test]
fn test_async_find_produces_same_results_as_sync_find() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block("foobar", "foo\r\nbar\r\n");
mock_terminal_model.simulate_block("barbaz", "bar baz\r\n");
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
// Run sync find for comparison.
let sync_run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
terminal_model.lock().block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// Run async find using TerminalFindModel.
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
test_model.update(&mut app, |model, ctx| {
model.async_find_controller.as_mut().unwrap().start_find(
&FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
BlockSortDirection::MostRecentLast,
ctx,
);
});
// Wait for async find to complete. The stream-based delivery processes
// results automatically; we just need to yield to the executor.
for _ in 0..100 {
let is_complete = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.map(|c| matches!(c.status(), AsyncFindStatus::Complete))
.unwrap_or(false)
});
if is_complete {
break;
}
// Small delay to let background task and stream delivery run.
warpui::r#async::Timer::after(std::time::Duration::from_millis(10)).await;
}
let (status, async_count) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.status().clone(), c.match_count())
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Async find should complete"
);
// Compare match counts.
let sync_count = sync_run.matches().count();
assert_eq!(
async_count, sync_count,
"Async find should produce same number of matches as sync find"
);
// Verify the matches are in the expected blocks and grids.
let model = terminal_model.lock();
for sync_match in sync_run.matches() {
if let BlockListMatch::CommandBlock(grid_match) = sync_match {
let async_matches = test_model.update(&mut app, |m, _ctx| {
m.async_find_controller
.as_ref()
.unwrap()
.matches_for_block_grid(grid_match.block_index, grid_match.grid_type)
.cloned()
});
assert!(
async_matches.is_some(),
"Async find should have matches for block {:?} grid {:?}",
grid_match.block_index,
grid_match.grid_type
);
// Convert async match to relative range and compare.
let block = model.block_list().block_at(grid_match.block_index).unwrap();
let grid = match grid_match.grid_type {
GridType::Output => block.output_grid().grid_handler(),
GridType::PromptAndCommand => block.prompt_and_command_grid().grid_handler(),
_ => continue,
};
let async_ranges: Vec<_> = async_matches
.unwrap()
.iter()
.filter_map(|m| m.to_range(grid))
.collect();
assert!(
async_ranges.contains(&grid_match.range),
"Async find should contain match {:?} in block {:?} grid {:?}",
grid_match.range,
grid_match.block_index,
grid_match.grid_type
);
}
}
});
}
#[test]
fn test_async_find_cancellation() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
// Create some blocks with content.
mock_terminal_model.simulate_block("cmd1", "line1\r\nline2\r\n");
mock_terminal_model.simulate_block("cmd2", "line3\r\nline4\r\n");
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
// Start a find operation.
test_model.update(&mut app, |model, ctx| {
model.async_find_controller.as_mut().unwrap().start_find(
&FindOptions {
query: Some("line".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
BlockSortDirection::MostRecentLast,
ctx,
);
});
// Verify we're scanning.
let is_scanning = test_model.update(&mut app, |model, _ctx| {
model.async_find_controller.as_ref().unwrap().is_scanning()
});
assert!(is_scanning, "Should be scanning after starting find");
// Cancel the find.
test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.cancel_current_find();
});
// Verify cancellation state.
let (is_scanning, has_active) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.is_scanning(), c.has_active_find())
});
assert!(!is_scanning, "Should not be scanning after cancellation");
assert!(has_active, "Config should still be set after cancellation");
// Clear results should reset everything.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.clear_results(ctx);
});
let (has_active, status) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.has_active_find(), c.status().clone())
});
assert!(
!has_active,
"Should not have active find after clear_results"
);
assert_eq!(
status,
AsyncFindStatus::Idle,
"Status should be Idle after clear_results"
);
});
}
#[test]
fn test_message_processing_updates_state() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
let mut controller = AsyncFindController::new(terminal_model);
// Manually set up state as if a find is in progress.
controller.set_test_status(AsyncFindStatus::Scanning);
model.async_find_controller = Some(controller);
model
});
// Process a BlockGridMatches message directly.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.process_message(
FindTaskMessage::BlockGridMatches {
block_index: BlockIndex(1),
grid_type: GridType::Output,
matches: vec![make_match_at(0, 0, 2), make_match_at(1, 0, 2)],
},
ctx,
);
});
// Verify state updates.
let (match_count, status, focused_idx) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.match_count(), c.status().clone(), c.focused_match_index())
});
assert_eq!(match_count, 2, "Should have 2 matches");
assert_eq!(
status,
AsyncFindStatus::Scanning,
"Status should still be scanning until Done is received"
);
assert_eq!(focused_idx, Some(0), "Should auto-focus first match");
// Process a Done message.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.process_message(FindTaskMessage::Done, ctx);
});
let status = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.unwrap()
.status()
.clone()
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Status should be Complete after Done message"
);
});
}
#[test]
fn test_block_invalidation_with_dirty_range() {
// Test that dirty range invalidation merges correctly with existing matches.
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at absolute rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![
make_match_at(5, 0, 2),
make_match_at(15, 0, 2),
make_match_at(25, 0, 2),
],
);
// Dirty range 10..=20 overlaps with match at row 15.
// New matches found in dirty range: rows 12 and 18.
let new_matches = vec![make_match_at(12, 0, 2), make_match_at(18, 0, 2)];
results.update_dirty_matches(block_index, grid_type, 10..=20, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
// Should have: 5, 12, 18, 25 (match at 15 was replaced).
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 12);
assert_eq!(stored[2].start_row(), 18);
assert_eq!(stored[3].start_row(), 25);
}
#[test]
fn test_focus_next_match_wraps_around() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Manually add some matches.
controller.block_results_mut().terminal_matches.insert(
(BlockIndex(0), GridType::Output),
vec![
make_match_at(0, 0, 2),
make_match_at(1, 0, 2),
make_match_at(2, 0, 2),
],
);
assert_eq!(controller.match_count(), 3);
// Default block_sort_direction is MostRecentLast, so:
// Down = decrement (toward newest/index 0)
// Up = increment (toward oldest/higher indices)
// Focus first match from None.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(0));
// Down decrements: 0 wraps to last index.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(2));
// Down decrements: 2 → 1.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(1));
// Down decrements: 1 → 0.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(0));
// Up increments: 0 → 1.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(1));
// Up increments: 1 → 2.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(2));
// Up wraps: 2 → 0.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(0));
}
#[test]
fn test_is_query_refinement() {
assert!(is_query_refinement("hel", "hello"));
assert!(is_query_refinement("foo", "foobar"));
assert!(!is_query_refinement("hello", "hel"));
assert!(!is_query_refinement("hello", "hello"));
assert!(!is_query_refinement("bar", "foo"));
assert!(!is_query_refinement("", "hello"));
}
#[test]
fn test_async_find_config_from_options() {
// Empty query should return None.
let options = FindOptions::default();
assert!(AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentLast).is_none());
// Query with only whitespace should return None.
let options = FindOptions {
query: Some(Arc::new(" ".to_string())),
..Default::default()
};
assert!(AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentLast).is_none());
// Valid query should return Some config.
let options = FindOptions {
query: Some(Arc::new("hello".to_string())),
is_case_sensitive: true,
is_regex_enabled: false,
blocks_to_include_in_results: Some(vec![BlockIndex(0), BlockIndex(1)]),
};
let config = AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentFirst);
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.query.as_str(), "hello");
assert!(config.is_case_sensitive);
assert!(!config.is_regex_enabled);
assert_eq!(
config.blocks_to_include,
Some(vec![BlockIndex(0), BlockIndex(1)])
);
}
#[test]
fn test_block_find_results_total_count() {
let mut results = BlockFindResults::default();
assert_eq!(results.total_match_count(), 0);
// Add some terminal matches.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 1);
// Add more terminal matches.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(1));
results
.terminal_matches
.entry((BlockIndex(1), GridType::PromptAndCommand))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 3);
}
#[test]
fn test_block_find_results_remove_block() {
let mut results = BlockFindResults::default();
// Add matches for block 0 and block 1.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(0));
results
.terminal_matches
.entry((BlockIndex(0), GridType::PromptAndCommand))
.or_default()
.push(make_match(0));
results
.terminal_matches
.entry((BlockIndex(1), GridType::Output))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 3);
// Remove block 0.
results.remove_block(BlockIndex(0));
assert_eq!(results.total_match_count(), 1);
// Block 1 should still have its matches.
assert!(results
.terminal_matches
.contains_key(&(BlockIndex(1), GridType::Output)));
}
#[test]
fn test_async_find_status_display() {
assert_eq!(format!("{}", AsyncFindStatus::Idle), "Idle");
assert_eq!(format!("{}", AsyncFindStatus::Complete), "Complete");
assert_eq!(format!("{}", AsyncFindStatus::Scanning), "Scanning");
}
#[test]
fn test_absolute_match_is_truncated() {
let match_at_row_5 = make_match(5);
// Not truncated when num_lines_truncated <= start row.
assert!(!match_at_row_5.is_truncated(0));
assert!(!match_at_row_5.is_truncated(5));
// Truncated when num_lines_truncated > start row.
assert!(match_at_row_5.is_truncated(6));
assert!(match_at_row_5.is_truncated(100));
}
#[test]
fn test_update_dirty_matches_empty_existing() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Update with new matches when there are no existing matches.
let new_matches = vec![make_match(5), make_match(10), make_match(15)];
results.update_dirty_matches(block_index, grid_type, 5..=15, new_matches.clone());
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 3);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 15);
}
#[test]
fn test_update_dirty_matches_prepend() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 20, 30.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(20), make_match(30)],
);
// Update with dirty range before all existing matches.
let new_matches = vec![make_match(5), make_match(10)];
results.update_dirty_matches(block_index, grid_type, 5..=10, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 20);
assert_eq!(stored[3].start_row(), 30);
}
#[test]
fn test_update_dirty_matches_append() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 10.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(10)],
);
// Update with dirty range after all existing matches.
let new_matches = vec![make_match(20), make_match(30)];
results.update_dirty_matches(block_index, grid_type, 20..=30, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 20);
assert_eq!(stored[3].start_row(), 30);
}
#[test]
fn test_update_dirty_matches_replace_middle() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(15), make_match(25)],
);
// Update dirty range 10..=20, which overlaps with the match at row 15.
// Replace it with matches at rows 12 and 18.
let new_matches = vec![make_match(12), make_match(18)];
results.update_dirty_matches(block_index, grid_type, 10..=20, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 12);
assert_eq!(stored[2].start_row(), 18);
assert_eq!(stored[3].start_row(), 25);
}
#[test]
fn test_update_dirty_matches_clear_range() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(15), make_match(25)],
);
// Update dirty range 10..=20 with no new matches (clears the match at row 15).
results.update_dirty_matches(block_index, grid_type, 10..=20, vec![]);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 25);
}
fn assert_async_focused_order_matches_sync(block_sort_direction: BlockSortDirection) {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block(
"ordtok command old ordtok",
"ordtok old output one\r\nold output ordtok two\r\n",
);
mock_terminal_model.simulate_block(
"ordtok command new ordtok",
"ordtok new output one\r\nnew output ordtok two\r\n",
);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let find_options = FindOptions {
query: Some("ordtok".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
};
let sync_order = app.update(|ctx| {
run_find_on_block_list(
find_options.clone(),
terminal_model.lock().block_list(),
&HashMap::new(),
block_sort_direction,
ctx,
)
.matches()
.filter_map(|m| match m {
BlockListMatch::CommandBlock(grid_match) => Some((
grid_match.block_index,
grid_match.grid_type,
*grid_match.range.start(),
*grid_match.range.end(),
)),
_ => None,
})
.collect::<Vec<_>>()
});
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.expect("Async find controller should exist in test.")
.start_find(&find_options, block_sort_direction, ctx);
});
for _ in 0..100 {
let is_complete = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.map(|c| matches!(c.status(), AsyncFindStatus::Complete))
.unwrap_or(false)
});
if is_complete {
break;
}
warpui::r#async::Timer::after(std::time::Duration::from_millis(10)).await;
}
let (status, async_match_count) = test_model.update(&mut app, |model, _ctx| {
let controller = model
.async_find_controller
.as_ref()
.expect("Async find controller should exist in test.");
(controller.status().clone(), controller.match_count())
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Async find should complete.",
);
assert_eq!(
async_match_count,
sync_order.len(),
"Async and sync paths should find the same number of terminal matches.",
);
let async_order_absolute = test_model.update(&mut app, |model, _ctx| {
let controller = model
.async_find_controller
.as_mut()
.expect("Async find controller should exist in test.");
let mut ordered = Vec::new();
for index in 0..controller.match_count() {
controller.focused_match_index = Some(index);
controller.update_cached_focused_match();
let focused = controller
.focused_terminal_match()
.expect("Every focused index should resolve to a terminal match in this test.");
ordered.push((focused.block_index, focused.grid_type, focused.range));
}
ordered
});
let async_order = {
let model = terminal_model.lock();
async_order_absolute
.into_iter()
.map(|(block_index, grid_type, absolute_match)| {
let block = model
.block_list()
.block_at(block_index)
.expect("Block should exist for focused async match.");
let grid = match grid_type {
GridType::Output => block.output_grid().grid_handler(),
GridType::PromptAndCommand => {
block.prompt_and_command_grid().grid_handler()
}
_ => panic!("Unexpected grid type in async focused match."),
};
let range = absolute_match
.to_range(grid)
.expect("Async focused match should map to a non-truncated range.");
(block_index, grid_type, *range.start(), *range.end())
})
.collect::<Vec<(BlockIndex, GridType, Point, Point)>>()
};
assert_eq!(
async_order, sync_order,
"Async focused ordering should match sync ordering for {:?}.",
block_sort_direction
);
});
}
#[test]
fn test_async_focused_order_matches_sync_most_recent_last() {
assert_async_focused_order_matches_sync(BlockSortDirection::MostRecentLast);
}
#[test]
fn test_async_focused_order_matches_sync_most_recent_first() {
assert_async_focused_order_matches_sync(BlockSortDirection::MostRecentFirst);
}
#[test]
fn test_focused_ai_match_resolves_only_ai_block() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Seed a single AI block with two matches. Default block sort direction is
// MostRecentLast, which reverses per-AI-block traversal at iteration time.
let view_id = EntityId::from_usize(42);
let ai_match_a = RichContentMatchId::default();
let ai_match_b = RichContentMatchId::default();
{
let results = controller.block_results_mut();
results
.ai_matches
.insert(view_id, vec![ai_match_a, ai_match_b]);
results.ai_total_indices.insert(view_id, TotalIndex(7));
}
assert_eq!(controller.match_count(), 2);
assert!(
controller.focused_terminal_match().is_none(),
"There are no terminal matches; focused_terminal_match should be None."
);
// MostRecentLast reverses per-AI-block iteration, so index 0 resolves to
// the last stored match (ai_match_b) and index 1 to the first.
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 0.");
assert_eq!(focused.view_id, view_id);
assert_eq!(focused.match_id, ai_match_b);
assert_eq!(focused.total_index, TotalIndex(7));
assert!(
controller.focused_terminal_match().is_none(),
"Terminal cache must be cleared when focus lands on an AI match."
);
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 1.");
assert_eq!(focused.match_id, ai_match_a);
}
#[test]
fn test_focused_ai_match_most_recent_first_preserves_storage_order() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Override the default MostRecentLast so we exercise the un-reversed
// per-AI-block iteration path.
controller.block_sort_direction = BlockSortDirection::MostRecentFirst;
let view_id = EntityId::from_usize(99);
let ai_match_a = RichContentMatchId::default();
let ai_match_b = RichContentMatchId::default();
{
let results = controller.block_results_mut();
results
.ai_matches
.insert(view_id, vec![ai_match_a, ai_match_b]);
results.ai_total_indices.insert(view_id, TotalIndex(3));
}
// MostRecentFirst iterates storage order: index 0 -> first, index 1 -> last.
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 0.");
assert_eq!(focused.match_id, ai_match_a);
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 1.");
assert_eq!(focused.match_id, ai_match_b);
}
#[test]
fn test_focused_match_index_walks_across_terminal_and_ai_blocks() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Two blocks at different TotalIndex positions:
// - AI block (TotalIndex 5, newer) with one match.
// - Terminal block at BlockIndex(0) (TotalIndex 1, older) with one
// Output match. The AI block is sorted first because its TotalIndex
// is higher.
let ai_view_id = EntityId::from_usize(11);
let ai_match = RichContentMatchId::default();
let terminal_match = make_match(0);
{
let results = controller.block_results_mut();
results.ai_matches.insert(ai_view_id, vec![ai_match]);
results.ai_total_indices.insert(ai_view_id, TotalIndex(5));
results
.terminal_matches
.insert((BlockIndex(0), GridType::Output), vec![terminal_match]);
results
.terminal_total_indices
.insert(BlockIndex(0), TotalIndex(1));
}
assert_eq!(controller.match_count(), 2);
// Index 0 -> AI match (newest block, AI block in this fixture).
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused_ai = controller
.focused_ai_match()
.expect("Index 0 should resolve to AI match.");
assert_eq!(focused_ai.view_id, ai_view_id);
assert_eq!(focused_ai.match_id, ai_match);
assert!(
controller.focused_terminal_match().is_none(),
"Terminal cache must be empty when focus is on AI block."
);
// Index 1 -> terminal match (older block).
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
assert!(
controller.focused_ai_match().is_none(),
"AI cache must be empty when focus is on terminal block."
);
let focused_terminal = controller
.focused_terminal_match()
.expect("Index 1 should resolve to terminal match.");
assert_eq!(focused_terminal.block_index, BlockIndex(0));
assert_eq!(focused_terminal.grid_type, GridType::Output);
}
+112 -25
View File
@@ -1,29 +1,25 @@
//! This module implements terminal find functionality for the blocklist.
use std::{collections::HashMap, iter, ops::RangeInclusive};
use std::collections::HashMap;
use std::iter;
use std::ops::RangeInclusive;
use galaxyui::{units::Lines, AppContext, EntityId};
use itertools::Itertools;
use galaxyui::units::Lines;
use galaxyui::{AppContext, EntityId};
use crate::terminal::{
model::{
block::Block,
blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem,
TotalIndex,
},
find::{FindConfig, RegexDFAs},
index::Point,
terminal_model::{BlockIndex, BlockSortDirection},
},
GridType,
use super::rich_content::{FindableRichContentHandle, RichContentMatchId};
use super::FindOptions;
use crate::terminal::model::block::Block;
use crate::terminal::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem, TotalIndex,
};
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::GridType;
use crate::view_components::find::FindDirection;
use super::{
rich_content::{FindableRichContentHandle, RichContentMatchId},
FindOptions,
};
/// Runs a find operation on the blocklist using the given `options` and returns a
/// `BlockListFindRun` with the results.
///
@@ -243,6 +239,18 @@ pub struct BlockGridMatch {
}
/// Represents a single find match in the blocklist.
///
/// Match values are snapshots of the find run that produced them. The grid
/// `range` on `CommandBlock` and the `index` on `RichContent` are captured at
/// scan time and can be invalidated by subsequent block list mutations (new
/// blocks, removals, rich content rescans, etc.). Callers should consume
/// cloned values inline; long-lived storage outside a `BlockListFindRun` is
/// not supported.
///
/// TODO(vkodithala): The `RichContent` variant mirrors `AsyncFocusedAiMatch` in the async
/// path. Both derive `Clone` even though their contents are short-lived;
/// explore removing `Clone` from both in a future PR to enforce the snapshot
/// contract in the type system.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockListMatch {
CommandBlock(BlockGridMatch),
@@ -288,6 +296,40 @@ impl BlockListMatch {
_ => false,
}
}
/// Returns `true` if `self` and `other` refer to the same matched span, ignoring transient
/// state like `is_filtered`.
fn same_span(&self, other: &BlockListMatch) -> bool {
match (self, other) {
(
BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: g1,
range: r1,
block_index: b1,
..
}),
BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: g2,
range: r2,
block_index: b2,
..
}),
) => g1 == g2 && r1 == r2 && b1 == b2,
(
BlockListMatch::RichContent {
match_id: id1,
view_id: v1,
index: i1,
},
BlockListMatch::RichContent {
match_id: id2,
view_id: v2,
index: i2,
},
) => id1 == id2 && v1 == v2 && i1 == i2,
_ => false,
}
}
}
/// Represents the result of a find "run" on the blocklist.
@@ -471,11 +513,17 @@ impl BlockListFindRun {
return self;
};
// Remember the currently focused match so we can relocate it after splicing.
let old_focused_match = self
.raw_focused_match_index
.and_then(|i| self.matches.get(i).cloned());
let old_block_matches_start_index = self
.matches
.iter()
.position(|find_match| find_match.matches_block(block_index));
let mut new_matches = run_find_on_block(dfas, block, block_index, block_sort_direction);
let new_matches = run_find_on_block(dfas, block, block_index, block_sort_direction);
let new_block_match_count = new_matches.len();
if let Some(start_index) = old_block_matches_start_index {
let end_index = old_block_matches_start_index
.and_then(|i| {
@@ -486,21 +534,60 @@ impl BlockListFindRun {
})
.unwrap_or(self.matches.len());
let old_block_match_count = end_index - start_index;
// Splice in the new matches where the old block matches used to exist.
self.matches.splice(start_index..end_index, new_matches);
// Adjust the focused match index so it still points to the same match.
if let Some(focused_index) = self.raw_focused_match_index {
if focused_index >= start_index && focused_index < end_index {
// The focused match was inside the rerun block. Try to find the same
// match (by span identity) in the new results.
self.raw_focused_match_index = old_focused_match
.as_ref()
.and_then(|old_match| {
self.matches[start_index..(start_index + new_block_match_count)]
.iter()
.position(|m| m.same_span(old_match))
.map(|p| start_index + p)
})
.or_else(|| {
// The old match no longer exists; clamp to a valid index.
if self.matches.is_empty() {
None
} else {
Some(focused_index.min(self.matches.len() - 1))
}
});
} else if focused_index >= end_index {
// The focused match was after the rerun block. Shift by the change in
// match count so it continues to point at the same match.
let new_index = focused_index + new_block_match_count - old_block_match_count;
self.raw_focused_match_index =
Some(new_index.min(self.matches.len().saturating_sub(1)));
}
// If focused_index < start_index the match is before the rerun block and
// needs no adjustment.
}
} else {
let mut new_matches = new_matches;
new_matches.append(&mut self.matches);
self.matches = new_matches;
// All previous indices shifted forward by the number of newly prepended matches.
if let Some(focused_index) = self.raw_focused_match_index {
self.raw_focused_match_index = Some(focused_index + new_block_match_count);
}
}
if self.matches.is_empty() {
self.raw_focused_match_index = None;
} else if let Some(mut focused_match_index) = self.raw_focused_match_index {
// Ensure the focused match index is still valid.
while focused_match_index >= self.matches.len() {
focused_match_index = focused_match_index.saturating_sub(1);
} else if let Some(focused_match_index) = self.raw_focused_match_index {
// Final bounds check.
if focused_match_index >= self.matches.len() {
self.raw_focused_match_index = Some(self.matches.len() - 1);
}
self.raw_focused_match_index = Some(focused_match_index);
}
self
@@ -663,5 +750,5 @@ fn update_matches_for_filtered_block<'a>(
}
#[cfg(test)]
#[path = "block_list_test.rs"]
#[path = "block_list_tests.rs"]
mod tests;
@@ -3,17 +3,15 @@ use std::collections::HashMap;
use galaxyui::App;
use itertools::Itertools;
use crate::terminal::{
block_filter::BlockFilterQuery,
find::{
model::{block_list::run_find_on_block_list, FindOptions},
BlockGridMatch,
},
model::{index::Point, terminal_model::BlockSortDirection},
GridType, TerminalModel,
};
use super::{BlockListFindRun, BlockListMatch};
use crate::terminal::block_filter::BlockFilterQuery;
use crate::terminal::find::model::block_list::run_find_on_block_list;
use crate::terminal::find::model::FindOptions;
use crate::terminal::find::BlockGridMatch;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::{GridType, TerminalModel};
use crate::view_components::find::FindDirection;
impl BlockListFindRun {
fn all_matches(&self) -> &[BlockListMatch] {
@@ -353,3 +351,142 @@ fn test_run_find_on_block_list_with_filtered_block() {
);
});
}
/// Regression test for https://github.com/warpdotdev/warp/issues/9542
///
/// When the active block's output is still streaming and the find results are refreshed,
/// the focused match must remain on the same text span even though new matches are
/// inserted before it in the match vector.
#[test]
fn test_rerun_on_block_preserves_focused_match_in_active_block() {
App::test((), |mut app| async move {
let mut mock_terminal_model = TerminalModel::mock(None, None);
// Block 1: a finished block.
mock_terminal_model.simulate_block("echo bar", "bar\r\n");
// Block 2: a long-running block whose command also matches so there are both
// output and prompt matches. This lets us navigate to the prompt match and then
// verify it stays focused after new output matches are spliced in before it.
mock_terminal_model.simulate_long_running_block("barserver", "request bar\r\n");
let last_block_index: BlockIndex = 2.into();
let mut run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
mock_terminal_model.block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// In MostRecentLast the match order for block 2 is:
// [0] Output row 0, col 8..=10 ("bar" in "request bar")
// [1] Prompt row 0, col 0..=2 ("bar" in "barserver")
// Navigate "Up" once to move from the output match to the prompt match.
run.focus_next_match(FindDirection::Up, BlockSortDirection::MostRecentLast);
let focused_before = run.focused_match().cloned();
assert_eq!(
focused_before,
Some(BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: GridType::PromptAndCommand,
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
block_index: last_block_index,
is_filtered: false,
}))
);
// Simulate more streaming output that introduces new output matches before the
// prompt match in the match vector.
mock_terminal_model.process_bytes("request bar\r\nrequest bar\r\n");
let block = mock_terminal_model
.block_list()
.block_at(last_block_index)
.unwrap();
let run = run.rerun_on_block(block, last_block_index, BlockSortDirection::MostRecentLast);
// The focused match must still be the same prompt span, even though new output
// matches were inserted before it in the active block's match slice.
assert_eq!(run.focused_match().cloned(), focused_before);
});
}
/// Regression test for https://github.com/warpdotdev/warp/issues/9542
///
/// When the user is focused on a match in an older (finished) block and the active block
/// receives new streaming output, the focus must not drift to a different match.
#[test]
fn test_rerun_on_block_preserves_focused_match_in_older_block() {
App::test((), |mut app| async move {
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block("echo bar", "bar\r\n");
mock_terminal_model.simulate_long_running_block("server", "request bar\r\n");
let last_block_index: BlockIndex = 2.into();
let older_block_index: BlockIndex = 1.into();
let mut run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
mock_terminal_model.block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// Navigate past the active block's matches to reach block 1's output match.
// Matches order (MostRecentLast): block 2 output, block 2 prompt ("server" has no
// match), block 1 output, block 1 prompt.
// Initial focus is at index 0 (block 2 output row 0).
// "Up" in MostRecentLast moves toward older blocks (higher index).
let match_count = run.all_matches().len();
for _ in 0..match_count {
if run
.focused_match()
.is_some_and(|m| m.matches_block(older_block_index))
{
break;
}
run.focus_next_match(FindDirection::Up, BlockSortDirection::MostRecentLast);
}
let focused_before = run.focused_match().cloned();
assert!(
focused_before
.as_ref()
.is_some_and(|m| m.matches_block(older_block_index)),
"expected focus on block 1, got {focused_before:?}"
);
let ui_index_before = run.focused_match_index();
// Simulate new streaming output in the active block.
mock_terminal_model.process_bytes("request bar\r\nrequest bar\r\n");
let block = mock_terminal_model
.block_list()
.block_at(last_block_index)
.unwrap();
let run = run.rerun_on_block(block, last_block_index, BlockSortDirection::MostRecentLast);
// The focused match must still be the same span in block 1.
assert_eq!(run.focused_match().cloned(), focused_before);
// The UI index should have shifted to account for the newly inserted matches.
assert_ne!(
run.focused_match_index(),
ui_index_before,
"UI index should change when new matches are inserted before the focused match"
);
});
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub trait FindableRichContentView: View {
/// New rich content views do _not_ require a new `FindableRichContentHandle` implementation;
/// this is an implementation detail of the `FindModel`-internal usage of the
/// `FindableRichContentView` trait.
pub(super) trait FindableRichContentHandle {
pub(crate) trait FindableRichContentHandle {
fn run_find(&self, options: &FindOptions, ctx: &mut AppContext) -> Vec<RichContentMatchId>;
fn clear_matches(&self, ctx: &mut AppContext);
+2 -2
View File
@@ -1,10 +1,10 @@
//! Exports helper test-only methods for use in unit and integration tests.
use itertools::Itertools;
use super::block_list::BlockListMatch;
use super::{BlockListFindRun, TerminalFindModel};
use crate::terminal::model::terminal_model::BlockIndex;
use super::{block_list::BlockListMatch, BlockListFindRun, TerminalFindModel};
impl TerminalFindModel {
pub fn visible_block_list_match_count(&self) -> usize {
self.block_list_find_run
+33
View File
@@ -0,0 +1,33 @@
use std::collections::HashMap;
use std::ffi::OsString;
use crate::channel::ChannelState;
pub(crate) const FOCUS_URL_ENV: &str = "WARP_FOCUS_URL";
pub(crate) const TERMINAL_SESSION_UUID_ENV: &str = "WARP_TERMINAL_SESSION_UUID";
pub(crate) fn session_focus_url(session_uuid_hex: &str) -> String {
format!(
"{}://session/{session_uuid_hex}",
ChannelState::url_scheme()
)
}
pub(crate) fn add_session_focus_env_vars(
env_vars: &mut HashMap<OsString, OsString>,
session_uuid: &[u8],
) {
let session_uuid_hex = hex::encode(session_uuid);
env_vars.insert(
OsString::from(TERMINAL_SESSION_UUID_ENV),
OsString::from(session_uuid_hex.clone()),
);
env_vars.insert(
OsString::from(FOCUS_URL_ENV),
OsString::from(session_focus_url(&session_uuid_hex)),
);
}
#[cfg(test)]
#[path = "focus_env_tests.rs"]
mod tests;
+29
View File
@@ -0,0 +1,29 @@
use std::collections::HashMap;
use std::ffi::OsString;
use super::{add_session_focus_env_vars, FOCUS_URL_ENV, TERMINAL_SESSION_UUID_ENV};
use crate::channel::ChannelState;
#[test]
fn focus_env_vars_point_at_session_deeplink() {
let uuid = [
0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66, 0x55, 0x44, 0x00,
0x00,
];
let mut env_vars = HashMap::new();
add_session_focus_env_vars(&mut env_vars, &uuid);
let expected_hex = "550e8400e29b41d4a716446655440000";
assert_eq!(
env_vars.get(&OsString::from(TERMINAL_SESSION_UUID_ENV)),
Some(&OsString::from(expected_hex))
);
assert_eq!(
env_vars.get(&OsString::from(FOCUS_URL_ENV)),
Some(&OsString::from(format!(
"{}://session/{expected_hex}",
ChannelState::url_scheme()
)))
);
}
+5 -2
View File
@@ -1,7 +1,10 @@
use std::collections::HashSet;
use crate::{banner::BannerState, resource_center::Tip};
use galaxy_core::settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
use galaxy_core::settings::macros::define_settings_group;
use galaxy_core::settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
use crate::banner::BannerState;
use crate::resource_center::Tip;
define_settings_group!(GeneralSettings, settings: [
show_warning_before_quitting: ShowWarningBeforeQuitting {
+24 -30
View File
@@ -1,42 +1,17 @@
mod cell_glyph_cache;
mod cell_type;
use crate::terminal::grid_size_util::calculate_grid_baseline_position;
use crate::terminal::model::ansi::{Color, CursorShape, CursorStyle};
use crate::terminal::model::cell::{Cell, Flags};
use crate::terminal::{color, SizeInfo};
use crate::terminal::model::grid::Dimensions;
use crate::terminal::model::index::Point;
use crate::terminal::model::selection::SelectionPoint;
use crate::terminal::model::{ObfuscateSecrets, SecretHandle};
use crate::themes::theme::GalaxyTheme;
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
use core::mem;
use galaxy_core::features::FeatureFlag;
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui::color::ColorU;
use galaxyui::elements::{Border, CornerRadius, Fill, Radius, DEFAULT_UI_LINE_HEIGHT_RATIO};
use galaxyui::fonts::{FamilyId, FontId, Properties, Style, Weight};
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
use galaxyui::platform::LineStyle;
use galaxyui::text_layout::{Line, StyleAndFont, TextStyle, DEFAULT_TOP_BOTTOM_RATIO};
use galaxyui::units::{IntoLines as _, Lines, Pixels};
use galaxyui::{AppContext, Element, EntityId, PaintContext, Scene, SingletonEntity};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::ops::{Range, RangeInclusive};
use lazy_static::lazy_static;
use num_traits::Float as _;
use std::cmp::Ordering;
use std::ops::Range;
use std::{collections::HashMap, ops::RangeInclusive};
use unicode_width::UnicodeWidthChar;
pub use self::cell_glyph_cache::CellGlyphCache;
use self::cell_type::{CellType, IsFocused, Secret};
use super::block_filter::{BLOCK_FILTER_DOTTED_LINE_DASH, BLOCK_FILTER_DOTTED_LINE_WIDTH};
use super::blockgrid_renderer::GridRenderParams;
use super::model::char_or_str::CharOrStr;
@@ -45,6 +20,16 @@ use super::model::grid::RespectDisplayedOutput;
use super::model::image_map::{ImagePlacementData, StoredImageMetadata};
use super::model::terminal_model::RangeInModel;
use crate::settings::EnforceMinimumContrast;
use crate::terminal::grid_size_util::calculate_grid_baseline_position;
use crate::terminal::model::ansi::{Color, CursorShape, CursorStyle};
use crate::terminal::model::cell::{Cell, Flags};
use crate::terminal::model::grid::Dimensions;
use crate::terminal::model::index::Point;
use crate::terminal::model::selection::SelectionPoint;
use crate::terminal::model::{ObfuscateSecrets, SecretHandle};
use crate::terminal::{color, SizeInfo};
use crate::themes::theme::WarpTheme;
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
// The scale factor of the cursor relative to the cursor width.
const CURSOR_THICKNESS_SCALE_FACTOR: f32 = 0.15;
@@ -1134,6 +1119,15 @@ fn render_grid_with_ligatures<'a>(
None => {
// If there are no non-empty cells in the entire row, we can skip it entirely
if marked_text.peek().is_none() {
if let Some(sampler) = bg_color_sampler.as_deref_mut() {
// Empty ligature-rendered rows still represent the terminal's default
// background. Keep them in the sampler so transient colored rows, such as
// tmux's status line during startup, do not dominate the inferred
// background used by surrounding UI.
for _ in 0..grid.columns() {
sampler.sample(ColorU::transparent_black());
}
}
continue;
}
grid.columns() - 1
@@ -2804,5 +2798,5 @@ fn render_dotted_line(
}
#[cfg(test)]
#[path = "grid_renderer_test.rs"]
#[path = "grid_renderer_tests.rs"]
pub mod tests;
@@ -1,14 +1,13 @@
//! This module defines CellGlyphCache, a struct which manages the caching of glyph values for cells
//! when rendering Grids within Warp.
use galaxyui::elements::DEFAULT_LINE_HEIGHT_RATIO;
use std::collections::HashMap;
use galaxyui::elements::DEFAULT_LINE_HEIGHT_RATIO;
use galaxyui::fonts::{Cache as FontCache, FamilyId, FontId, GlyphId, Properties};
use galaxyui::platform::LineStyle;
use galaxyui::text_layout::{StyleAndFont, DEFAULT_TOP_BOTTOM_RATIO};
use galaxyui::PaintContext;
use std::collections::HashMap;
/// Stores cached glyph values for characters/strings. Note that we normally only need to look up
/// characters - we only look up strings in the case of zerowidth characters (which act as modifiers
/// to the first character e.g. emoji variant selectors). We have 2 separate caches internally for
+9 -12
View File
@@ -1,18 +1,15 @@
// The color-mapping helpers (`compute_fg_rgb`, `compute_bg_rgb`, and
// `get_override_color`) below are adapted from the alacritty_terminal crate
// under the Apache license; see: crates/warp_terminal/src/model/LICENSE-ALACRITTY.
use pathfinder_color::ColorU;
use crate::{
terminal::{
color,
model::{
ansi::{color_index, Color, NamedColor},
cell::{Cell, Flags},
ObfuscateSecrets,
},
},
util::color::OPAQUE,
};
use super::{BLOCK_FILTER_MATCH_COLOR, FOCUSED_MATCH_COLOR, MATCH_COLOR, URL_COLOR};
use crate::terminal::color;
use crate::terminal::model::ansi::{color_index, Color, NamedColor};
use crate::terminal::model::cell::{Cell, Flags};
use crate::terminal::model::ObfuscateSecrets;
use crate::util::color::OPAQUE;
#[derive(PartialEq)]
pub(super) struct Secret {
@@ -1,12 +1,13 @@
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use warpui::fonts::Cache as FontCache;
use warpui::units::{IntoLines, Lines, Pixels};
use super::{active_or_next_match, CachedBackgroundColor};
use crate::terminal::grid_size_util::calculate_grid_baseline_position;
use crate::terminal::model::index::Point;
use crate::terminal::model::selection::SelectionPoint;
use crate::terminal::{grid_renderer, SizeInfo};
use galaxyui::fonts::Cache as FontCache;
use galaxyui::units::{IntoLines, Lines, Pixels};
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::{vec2f, Vector2F};
fn rect_from_points(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> RectF {
RectF::from_points(vec2f(min_x, min_y), vec2f(max_x, max_y))
+2 -2
View File
@@ -5,8 +5,8 @@ use galaxyui::fonts::Cache as FontCache;
use galaxyui::fonts::FamilyId;
use galaxyui::text_layout::ComputeBaselinePositionFn;
use num_traits::Zero;
use pathfinder_geometry::vector::vec2f;
use pathfinder_geometry::vector::Vector2F;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxyui::fonts::{Cache as FontCache, FamilyId};
/// Computes the grid cell size given the font and size at which the grid should
/// be rendered. We use a similar algorithm to Alacritty to do this, where the
+14 -33
View File
@@ -1,31 +1,23 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use chrono::{DateTime, Local, TimeZone as _};
use futures::Future;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use galaxy_core::command::ExitCode;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use super::{
model::block::{AgentInteractionMetadata, Block, SerializedAIMetadata, SerializedBlock},
shell::ShellType,
};
use crate::{
cloud_object::{
model::{persistence::CloudModel, view::CloudViewModel},
Space,
},
server::ids::{ClientId, HashableId as _, SyncId},
terminal::model::session::{Session, SessionId},
util::dedupe_from_last,
workflows::{
local_workflows::LocalWorkflows, workflow::Workflow, WorkflowId, WorkflowSource,
WorkflowType,
},
};
use super::model::block::{AgentInteractionMetadata, Block, SerializedAIMetadata, SerializedBlock};
use super::shell::ShellType;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel;
use crate::cloud_object::Space;
use crate::server::ids::{ClientId, HashableId as _, SyncId};
use crate::terminal::model::session::{Session, SessionId};
use crate::util::dedupe_from_last;
use crate::workflows::local_workflows::LocalWorkflows;
use crate::workflows::workflow::Workflow;
use crate::workflows::{WorkflowId, WorkflowSource, WorkflowType};
mod up_arrow;
pub(crate) use up_arrow::UpArrowHistoryConfig;
@@ -489,17 +481,6 @@ impl History {
}
}
/// Returns an iterator over a tuple of (count, &HistoryEntry) for all commands in the history.
/// where count is the number of times the command has been run.
pub fn command_summaries(&self, hostname: String) -> Vec<(u32, &HistoryEntry)> {
self.persisted_commands_summary
.iter()
.filter(|(shell_host, _)| shell_host.hostname == hostname)
.flat_map(|(_, summaries)| summaries.values())
.map(|summary| (summary.count, &summary.most_recent_entry))
.collect()
}
pub fn all_live_session_ids(&self) -> HashSet<SessionId> {
self.session_id_to_shell_host.keys().cloned().collect()
}
+2 -4
View File
@@ -3,15 +3,13 @@ use std::collections::HashSet;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, EntityId, SingletonEntity};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::blocklist::InputConfig;
use super::History;
use crate::ai::blocklist::{BlocklistAIHistoryModel, InputConfig};
use crate::input_suggestions::HistoryInputSuggestion;
use crate::settings::AISettings;
use crate::suggestions::ignored_suggestions_model::{IgnoredSuggestionsModel, SuggestionType};
use crate::terminal::model::session::SessionId;
use super::History;
/// Controls which item types are included in up-arrow history results.
#[derive(Copy, Clone, Debug)]
pub(crate) struct UpArrowHistoryConfig {
+16 -20
View File
@@ -1,18 +1,7 @@
use crate::{
ai::agent::conversation::AIConversationId,
terminal::{
model::{
block::{AgentInteractionMetadata, SerializedAIMetadata, SerializedBlock},
bootstrap::BootstrapStage,
session::command_executor::testing::TestCommandExecutor,
session::{BootstrapSessionType, Session, SessionId, SessionInfo},
test_utils::TestBlockBuilder,
},
shell::ShellType,
History,
},
test_util::{Stub, VirtualFS},
};
use std::path::PathBuf;
use std::pin::pin;
use std::sync::Arc;
use chrono::Local;
use futures::future::join_all;
use futures::Future;
@@ -21,11 +10,18 @@ use galaxy_core::command::ExitCode;
use galaxyui::{App, ModelHandle};
use itertools::Itertools;
use std::path::PathBuf;
use std::pin::pin;
use std::sync::Arc;
use super::{HistoryEntry, HistoryEvent, PersistedCommand, ShellHost};
use crate::ai::agent::conversation::AIConversationId;
use crate::terminal::model::block::{
AgentInteractionMetadata, SerializedAIMetadata, SerializedBlock,
};
use crate::terminal::model::bootstrap::BootstrapStage;
use crate::terminal::model::session::command_executor::testing::TestCommandExecutor;
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionId, SessionInfo};
use crate::terminal::model::test_utils::TestBlockBuilder;
use crate::terminal::shell::ShellType;
use crate::terminal::History;
use crate::test_util::{Stub, VirtualFS};
impl History {
/// Returns a Future that completes when `History` is initialized for all sessions with IDs in
@@ -43,7 +39,7 @@ impl History {
if !is_session_initialized {
let (tx, rx) = async_channel::unbounded();
let history_handle_clone = history_handle.clone();
history_handle.update(app, move |_, ctx| {
app.update(|ctx| {
ctx.subscribe_to_model(&history_handle_clone, move |_, event, _| {
let HistoryEvent::Initialized(event_id) = event;
if session_id == *event_id {
+2698 -806
View File
File diff suppressed because it is too large Load Diff
+189 -70
View File
@@ -1,41 +1,35 @@
use super::{
common::{
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
add_workflow_info_overlay, maybe_add_buy_credits_banner,
wrap_input_with_terminal_padding_and_focus_handler,
},
Input, InputAction, InputDropTargetData,
};
use crate::{
ai::blocklist::{
agent_view::{
agent_view_bg_fill,
shortcuts::{render_agent_shortcuts_view, AgentShortcutsViewContext},
AgentViewState,
},
InputType,
},
appearance::Appearance,
context_chips::spacing::{self},
features::FeatureFlag,
settings::InputModeSettings,
terminal::{settings::TerminalSettings, view::TerminalAction},
BlocklistAIHistoryModel,
};
use galaxy_cli::agent::Harness;
use galaxy_core::settings::Setting;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::Expanded;
use galaxyui::{
elements::{
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, DropTarget, Element, EventHandler, Flex, Hoverable, MainAxisSize,
OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Stack, Text, XAxisAnchor, YAxisAnchor,
},
presenter::ChildView,
AppContext, SingletonEntity as _,
use galaxyui::elements::{
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, DropTarget, Element, Empty, EventHandler, Expanded, Flex, Hoverable,
MainAxisSize, OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
};
use pathfinder_color::ColorU;
use galaxyui::presenter::ChildView;
use galaxyui::{AppContext, SingletonEntity as _};
use super::common::{
add_command_xray_overlay, add_input_suggestions_overlays, add_voltron_overlay,
add_workflow_info_overlay, maybe_add_buy_credits_banner,
wrap_input_with_terminal_padding_and_focus_handler,
};
use super::{Input, InputAction, InputDropTargetData};
use crate::ai::blocklist::agent_view::shortcuts::{
render_agent_shortcuts_view, AgentShortcutsViewContext,
};
use crate::ai::blocklist::agent_view::{agent_view_bg_fill, AgentViewState};
use crate::ai::blocklist::InputType;
use crate::ai::harness_availability::HarnessAvailabilityModel;
use crate::appearance::Appearance;
use crate::context_chips::spacing::{self};
use crate::editor::position_id_for_cursor;
use crate::features::FeatureFlag;
use crate::settings::InputModeSettings;
use crate::terminal::settings::TerminalSettings;
use crate::terminal::view::TerminalAction;
use crate::BlocklistAIHistoryModel;
pub(super) const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.;
@@ -67,10 +61,14 @@ impl Input {
pub fn is_cloud_mode_input_v2_composing(&self, app: &AppContext) -> bool {
FeatureFlag::CloudModeInputV2.is_enabled()
&& FeatureFlag::CloudMode.is_enabled()
&& self
.ambient_agent_view_model
.as_ref(app)
.is_configuring_ambient_agent()
&& self.ambient_agent_view_model().is_some_and(|model| {
let view_model = model.as_ref(app);
view_model.is_configuring_ambient_agent()
// The handoff pane intentionally stays on the existing input UI even
// when V2 is on — V2 is for fresh cloud-mode runs only, and handoff has
// its own pre-spawn flow (submit interception).
&& !view_model.is_local_to_cloud_handoff()
})
}
/// Renders the input when there is an active `AgentView`.
@@ -119,24 +117,29 @@ impl Input {
}
let show_harness_row = FeatureFlag::CloudMode.is_enabled()
&& FeatureFlag::AgentHarness.is_enabled()
&& HarnessAvailabilityModel::as_ref(app).should_show_harness_selector()
&& self
.ambient_agent_view_model
.as_ref(app)
.is_configuring_ambient_agent();
.ambient_agent_view_model()
.is_some_and(|ambient_agent_model| {
ambient_agent_model
.as_ref(app)
.is_configuring_ambient_agent()
});
if show_harness_row {
// Temporarily render the harness selector in the cloud mode UDI until we fully
// implement the new designs.
let harness_row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_child(ChildView::new(&self.harness_selector).finish())
.finish();
column.add_child(
Container::new(harness_row)
.with_padding_top(spacing::UDI_CHIP_MARGIN)
.with_padding_bottom(4.)
.finish(),
);
if let Some(harness_selector) = self.harness_selector() {
// Temporarily render the harness selector in the cloud mode UDI until we fully
// implement the new designs.
let harness_row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_child(ChildView::new(harness_selector).finish())
.finish();
column.add_child(
Container::new(harness_row)
.with_padding_top(spacing::UDI_CHIP_MARGIN)
.with_padding_bottom(4.)
.finish(),
);
}
}
let terminal_spacing = TerminalSettings::as_ref(app)
@@ -211,7 +214,9 @@ impl Input {
)
.finish();
let border_color = if !self.ai_input_model.as_ref(app).is_ai_input_enabled()
let border_color = if self.handoff_compose_state.as_ref(app).is_active() {
appearance.theme().ansi_fg_magenta()
} else if !self.ai_input_model.as_ref(app).is_ai_input_enabled()
&& !self.suggestions_mode_model.as_ref(app).is_slash_commands()
&& !self.slash_command_model.as_ref(app).state().is_detected_command()
// If NLD, don't color the border if the input is empty, because the current
@@ -259,7 +264,9 @@ impl Input {
.is_profile_selector()
{
column.add_child(ChildView::new(&self.inline_profile_selector_view).finish());
} else if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
} else if self.suggestions_mode_model.as_ref(app).is_slash_commands()
&& !self.is_cloud_mode_input_v2_composing(app)
{
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
} else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() {
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
@@ -324,7 +331,13 @@ impl Input {
app,
));
}
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
column.add_child(ChildView::new(&self.agent_status_view).finish());
if let Some(panel) = self.queued_prompts_panel.as_ref() {
if panel.as_ref(app).should_render(app) {
column.add_child(ChildView::new(panel).finish());
}
}
column.add_child(input);
let mut outer_stack = Stack::new().with_constrain_absolute_children();
outer_stack.add_child(column.finish());
@@ -369,6 +382,7 @@ impl Input {
.on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
ctx.dispatch_typed_action(InputAction::FocusInputBox);
ctx.dispatch_typed_action(InputAction::DismissCloudModeV2SlashCommandsMenu);
DispatchEventResult::StopPropagation
})
.finish()
@@ -401,14 +415,56 @@ impl Input {
);
}
if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
if let Some(view) = self.cloud_mode_v2_slash_commands_view.as_ref() {
let cursor_position = position_id_for_cursor(self.editor.id());
stack.add_positioned_overlay_child(
ChildView::new(view).finish(),
OffsetPositioning::from_axes(
PositioningAxis::relative_to_stack_child(
&cursor_position,
PositionedElementOffsetBounds::WindowByPosition,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_stack_child(
&cursor_position,
PositionedElementOffsetBounds::Unbounded,
OffsetType::Pixel(4.),
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
),
),
);
}
}
if let Some(selected_workflow_state) = self.workflows_state.selected_workflow_state.as_ref()
{
if selected_workflow_state.should_show_more_info_view {
add_workflow_info_overlay(
&mut stack,
selected_workflow_state,
self.size_info(app).pane_height_px().as_f32(),
menu_positioning,
let prompt_position = self.prompt_save_position_id();
let workflows_info_view = Container::new(
ChildView::new(&selected_workflow_state.more_info_view).finish(),
)
.finish();
stack.add_positioned_overlay_child(
ConstrainedBox::new(workflows_info_view)
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
.with_max_height(self.size_info(app).pane_height_px().as_f32() * 0.35)
.finish(),
OffsetPositioning::from_axes(
PositioningAxis::relative_to_stack_child(
&prompt_position,
PositionedElementOffsetBounds::WindowByPosition,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_stack_child(
&prompt_position,
PositionedElementOffsetBounds::Unbounded,
OffsetType::Pixel(0.),
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
),
),
);
}
}
@@ -458,6 +514,32 @@ impl Input {
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
}
pub(super) fn should_show_auth_secret_ftux(&self, app: &AppContext) -> bool {
let Some(view_model) = self.ambient_agent_view_model() else {
return false;
};
let vm = view_model.as_ref(app);
let harness = vm.selected_harness();
if harness == Harness::Oz {
return false;
}
// Skip FTUX for harnesses that have no auth secret types defined.
if crate::ai::auth_secret_types::auth_secret_types_for_harness(harness).is_empty() {
return false;
}
if let Some(ftux_view) = self.auth_secret_ftux_view() {
if ftux_view.as_ref(app).has_creation_state() {
return true;
}
}
if crate::ai::cloud_agent_settings::CloudAgentSettings::as_ref(app)
.is_harness_auth_ftux_completed(harness)
{
return false;
}
vm.selected_harness_auth_secret_name().is_none()
}
fn render_cloud_mode_v2_content(
&self,
appearance: &Appearance,
@@ -468,8 +550,20 @@ impl Input {
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(CLOUD_MODE_V2_TOP_ROW_GAP);
column.add_child(self.render_cloud_mode_v2_top_row());
column.add_child(self.render_cloud_mode_v2_input_container(appearance, app));
column.add_child(self.render_cloud_mode_v2_top_row(app));
if let Some(panel) = self.queued_prompts_panel.as_ref() {
if panel.as_ref(app).should_render(app) {
column.add_child(ChildView::new(panel).finish());
}
}
if self.should_show_auth_secret_ftux(app) {
column.add_child(self.render_auth_secret_ftux_content());
} else {
column.add_child(self.render_cloud_mode_v2_input_container(appearance, app));
}
Align::new(
ConstrainedBox::new(column.finish())
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
@@ -478,6 +572,13 @@ impl Input {
.finish()
}
fn render_auth_secret_ftux_content(&self) -> Box<dyn Element> {
match self.auth_secret_ftux_view() {
Some(view) => ChildView::new(view).finish(),
None => Empty::new().finish(),
}
}
fn render_cloud_mode_v2_history_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
if !self
.suggestions_mode_model
@@ -490,16 +591,31 @@ impl Input {
Some(ChildView::new(view).finish())
}
fn render_cloud_mode_v2_top_row(&self) -> Box<dyn Element> {
fn render_cloud_mode_v2_top_row(&self, app: &AppContext) -> Box<dyn Element> {
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(CLOUD_MODE_V2_TOP_ROW_INNER_GAP);
if let Some(host) = self.host_selector.as_ref() {
row.add_child(ChildView::new(host).finish());
// Only show the host selector when a default host is configured.
if let Some(host) = self.host_selector() {
if host.as_ref(app).has_default_host() {
row.add_child(ChildView::new(host).finish());
}
}
if let Some(harness_selector) = self.harness_selector() {
row.add_child(ChildView::new(harness_selector).finish());
}
if let Some(auth_secret_selector) = self.auth_secret_selector() {
let harness = self
.ambient_agent_view_model()
.map(|m| m.as_ref(app).selected_harness())
.unwrap_or(warp_cli::agent::Harness::Oz);
if harness != warp_cli::agent::Harness::Oz && !self.should_show_auth_secret_ftux(app) {
row.add_child(ChildView::new(auth_secret_selector).finish());
}
}
row.add_child(ChildView::new(&self.harness_selector).finish());
row.finish()
}
@@ -571,7 +687,10 @@ impl Input {
}
pub(super) fn render_ambient_agent_status_footer(&self, app: &AppContext) -> Box<dyn Element> {
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
let Some(ambient_agent_model) = self.ambient_agent_view_model() else {
return Empty::new().finish();
};
let ambient_agent_model = ambient_agent_model.as_ref(app);
let mut stack = Stack::new().with_constrain_absolute_children();
// Don't render status bar when agent has failed or is waiting for session
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct InputBufferModel {
impl InputBufferModel {
pub fn new(editor: &ViewHandle<EditorView>, ctx: &mut ModelContext<Self>) -> Self {
let editor_clone = editor.downgrade();
ctx.subscribe_to_view(editor, move |me, event, ctx| match event {
ctx.subscribe_to_view(editor, move |me, _, event, ctx| match event {
// This is intended to be the set of Editor view events that exhaustively
// capture any changes to editor contents or cursor position.
editor::Event::Edited(..)
+22 -31
View File
@@ -1,38 +1,29 @@
use crate::{
ai::blocklist::InputType,
appearance::Appearance,
context_chips::spacing,
features::FeatureFlag,
settings::{AppEditorSettings, InputModeSettings},
terminal::{
block_list_settings::BlockListSettings,
block_list_viewport::InputMode,
input::{
common::{
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
add_voltron_overlay, add_workflow_info_overlay,
should_show_terminal_input_message_bar,
wrap_input_with_terminal_padding_and_focus_handler,
},
get_input_box_top_border_width, InputDropTargetData,
},
settings::{SpacingMode, TerminalSettings},
view::TerminalAction,
warpify::render::{render_subshell_flag, render_subshell_flag_pole},
},
};
use galaxyui::{
elements::{
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
SavePosition, Stack,
},
AppContext, SingletonEntity,
};
use pathfinder_geometry::vector::vec2f;
use settings::Setting;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, SavePosition,
Stack,
};
use galaxyui::{AppContext, SingletonEntity};
use super::{should_render_prompt_using_editor_decorator_elements, Input, SubshellRenderState};
use crate::ai::blocklist::InputType;
use crate::appearance::Appearance;
use crate::context_chips::spacing;
use crate::features::FeatureFlag;
use crate::settings::{AppEditorSettings, InputModeSettings};
use crate::terminal::block_list_settings::BlockListSettings;
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::input::common::{
add_command_xray_overlay, add_input_suggestions_overlays, add_vim_status_to_stack,
add_voltron_overlay, add_workflow_info_overlay, should_show_terminal_input_message_bar,
wrap_input_with_terminal_padding_and_focus_handler,
};
use crate::terminal::input::{get_input_box_top_border_width, InputDropTargetData};
use crate::terminal::settings::{SpacingMode, TerminalSettings};
use crate::terminal::view::TerminalAction;
use crate::terminal::warpify::render::{render_subshell_flag, render_subshell_flag_pole};
impl Input {
/// Renders the classic input. This is used when the user has 'Honor PS1' enabled in settings,
+56 -20
View File
@@ -1,28 +1,27 @@
use galaxy_core::ui::color::contrast::MinimumAllowedContrast;
use galaxy_core::ui::color::ContrastingColor;
use galaxy_core::ui::theme::color::internal_colors;
use warpui::elements::{
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
};
use warpui::presenter::ChildView;
use warpui::{AppContext, SingletonEntity as _, ViewContext};
use super::common::{
add_input_suggestions_overlays, wrap_input_with_terminal_padding_and_focus_handler,
};
use super::{
common::{add_input_suggestions_overlays, wrap_input_with_terminal_padding_and_focus_handler},
Input, InputAction, InputDropTargetData, CLI_AGENT_RICH_INPUT_EDITOR_BOTTOM_PADDING,
CLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT, CLI_AGENT_RICH_INPUT_EDITOR_TOP_PADDING,
TERMINAL_VIEW_PADDING_LEFT,
};
use crate::{
appearance::Appearance,
context_chips::spacing,
editor::TextColors,
features::FeatureFlag,
terminal::{cli_agent_sessions::CLIAgentSessionsModel, view::TerminalAction},
};
use galaxy_core::ui::{
color::{contrast::MinimumAllowedContrast, ContrastingColor},
theme::color::internal_colors,
};
use galaxyui::{
elements::{
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
},
presenter::ChildView,
AppContext, SingletonEntity as _, ViewContext,
};
use crate::appearance::Appearance;
use crate::context_chips::spacing;
use crate::editor::{EnterAction, EnterSettings, TextColors};
use crate::features::FeatureFlag;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::view::TerminalAction;
impl Input {
/// Renders the CLI rich input (editor + CLI agent footer).
@@ -193,4 +192,41 @@ impl Input {
editor.set_text_colors(text_colors, ctx);
});
}
/// Configures the editor's enter-key behaviour for the CLI agent rich input.
///
/// When rich input is **open**, `enter` is always `Emit` so `input_enter`
/// runs first and handles inline-menu acceptance before any newline or
/// submit logic. `ctrl_enter` is `Emit` only when the toggle is ON
/// (submit on Ctrl+Enter); when the toggle is OFF it is
/// `InsertNewLineIfMultiLine` to restore baseline newline insertion.
///
/// When rich input is **closed**, `EnterSettings::default()` is restored.
pub(super) fn update_cli_agent_enter_settings(&mut self, ctx: &mut ViewContext<Self>) {
let rich_input_open =
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
let settings = if rich_input_open {
let submit_on_ctrl_enter =
*crate::settings::AISettings::as_ref(ctx).submit_on_ctrl_enter;
EnterSettings {
// Always Emit so input_enter handles menus before submit/newline.
enter: EnterAction::Emit,
// Toggle ON → Emit (submit path in input_ctrl_enter).
// Toggle OFF → InsertNewLineIfMultiLine (baseline newline).
ctrl_enter: if submit_on_ctrl_enter {
EnterAction::Emit
} else {
EnterAction::InsertNewLineIfMultiLine
},
..Default::default()
}
} else {
EnterSettings::default()
};
self.editor.update(ctx, |editor, _ctx| {
editor.set_enter_settings(settings);
});
}
}
@@ -1,26 +1,28 @@
use std::collections::HashSet;
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Radius, Text,
Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Radius,
};
use galaxyui::{
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
ViewHandle,
};
use pathfinder_color::ColorU;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::search::data_source::QueryFilter;
use crate::terminal::input::buffer_model::InputBufferModel;
use crate::search::data_source::{Query, QueryFilter};
use crate::search::mixer::SearchMixer;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::inline_history::{
AcceptHistoryItem, HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView,
AcceptHistoryItem, InlineHistoryMenuDataSource, InlineHistoryMenuEvent,
};
use crate::terminal::input::inline_menu::styles as inline_menu_styles;
use crate::terminal::input::inline_menu::{InlineMenuPositioner, InlineMenuTabConfig};
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::InputSuggestionsMode;
use crate::terminal::model::session::active_session::ActiveSession;
const MENU_MAX_HEIGHT: f32 = 168.;
@@ -37,7 +39,11 @@ const DROP_SHADOW_COLOR: ColorU = ColorU {
};
pub struct CloudModeV2HistoryMenuView {
inner: ViewHandle<InlineHistoryMenuView>,
menu_view: ViewHandle<InlineMenuView<AcceptHistoryItem>>,
mixer: ModelHandle<SearchMixer<AcceptHistoryItem>>,
buffer_model: ModelHandle<InputBufferModel>,
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
pending_initial_buffer_sync: bool,
}
impl CloudModeV2HistoryMenuView {
@@ -51,46 +57,125 @@ impl CloudModeV2HistoryMenuView {
buffer_model: ModelHandle<InputBufferModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let tab_configs = vec![InlineMenuTabConfig {
id: HistoryTab::Prompts,
label: "Prompts".to_string(),
filters: HashSet::from([QueryFilter::PromptHistory]),
}];
let inner = ctx.add_view(|ctx| {
InlineHistoryMenuView::new_with_tab_configs(
let data_source = ctx.add_model(|_| {
InlineHistoryMenuDataSource::new(
terminal_view_id,
active_session,
input_suggestions_model,
agent_view_controller,
positioner,
buffer_model,
tab_configs,
ctx,
agent_view_controller.clone(),
)
});
ctx.subscribe_to_view(&inner, |_, _, event, ctx| {
ctx.emit(event.clone());
ctx.notify();
let mixer = ctx.add_model(|ctx| {
let mut mixer = SearchMixer::<AcceptHistoryItem>::new();
mixer.add_sync_source(data_source, [QueryFilter::PromptHistory]);
mixer.run_query(prompts_query(""), ctx);
mixer
});
Self { inner }
let menu_view = ctx.add_typed_action_view(|ctx| {
InlineMenuView::new(
mixer.clone(),
positioner.clone(),
input_suggestions_model,
agent_view_controller,
ctx,
)
.with_compact_layout()
.with_dismiss_on_row_click()
});
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
InlineMenuEvent::AcceptedItem {
item: AcceptHistoryItem::AIPrompt { query_text },
..
} => {
ctx.emit(InlineHistoryMenuEvent::AcceptAIPrompt {
query_text: query_text.clone(),
});
}
InlineMenuEvent::SelectedItem {
item: AcceptHistoryItem::AIPrompt { query_text },
} => {
ctx.emit(InlineHistoryMenuEvent::SelectAIPrompt {
query_text: query_text.clone(),
});
}
InlineMenuEvent::Dismissed => {
me.suggestions_mode_model.update(ctx, |model, ctx| {
model.set_mode(InputSuggestionsMode::Closed, ctx);
});
}
InlineMenuEvent::NoResults => {
ctx.emit(InlineHistoryMenuEvent::NoResults);
}
InlineMenuEvent::AcceptedItem { .. }
| InlineMenuEvent::SelectedItem { .. }
| InlineMenuEvent::TabChanged => {}
});
ctx.subscribe_to_model(input_suggestions_model, |me, model, event, ctx| {
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
if model.as_ref(ctx).is_inline_history_menu() {
me.open_with_current_buffer(ctx);
}
});
ctx.subscribe_to_model(&buffer_model, |me, _, _: &InputBufferUpdateEvent, ctx| {
if !me
.suggestions_mode_model
.as_ref(ctx)
.is_inline_history_menu()
{
return;
}
if !me.pending_initial_buffer_sync {
return;
}
me.pending_initial_buffer_sync = false;
me.open_with_current_buffer(ctx);
});
Self {
menu_view,
mixer,
buffer_model,
suggestions_mode_model: input_suggestions_model.clone(),
pending_initial_buffer_sync: false,
}
}
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
self.inner.update(ctx, |v, ctx| v.select_up(ctx));
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
}
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
self.inner.update(ctx, |v, ctx| v.select_down(ctx));
// Mirror the legacy `InlineHistoryMenuView::select_down` behavior:
// pressing Down past the last item (or with no results) closes the
// history menu rather than wrapping back to the first item.
let should_close = self.menu_view.read(ctx, |v, _| {
let result_count = v.result_count();
let is_last_item_selected =
result_count > 0 && v.selected_idx().is_some_and(|idx| idx == result_count - 1);
is_last_item_selected || result_count == 0
});
if should_close {
ctx.emit(InlineHistoryMenuEvent::Close);
} else {
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
}
}
pub fn accept_selected(&self, ctx: &mut ViewContext<Self>) {
self.inner.update(ctx, |v, ctx| v.accept_selected_item(ctx));
self.menu_view
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
}
pub fn arm_initial_buffer_sync(&mut self, _ctx: &mut ViewContext<Self>) {
self.pending_initial_buffer_sync = true;
}
pub fn has_selection(&self, app: &AppContext) -> bool {
self.inner
self.menu_view
.as_ref(app)
.model()
.as_ref(app)
@@ -100,14 +185,35 @@ impl CloudModeV2HistoryMenuView {
/// Returns the currently selected AI prompt's query text, if any.
///
/// The cloud-mode V2 menu is restricted to `AcceptHistoryItem::AIPrompt`
/// items via its tab filters, so we only ever expect prompt selections.
/// The cloud-mode v2 menu is restricted to `AcceptHistoryItem::AIPrompt`
/// items via its data source filter, so we only ever expect prompt
/// selections; the other arms are unreachable but matched defensively.
pub fn selected_query_text(&self, app: &AppContext) -> Option<String> {
match self.inner.as_ref(app).model().as_ref(app).selected_item()? {
match self
.menu_view
.as_ref(app)
.model()
.as_ref(app)
.selected_item()?
{
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text.clone()),
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::Conversation { .. } => None,
}
}
fn open_with_current_buffer(&mut self, ctx: &mut ViewContext<Self>) {
let text = self.buffer_model.as_ref(ctx).current_value().to_owned();
self.mixer.update(ctx, |mixer, ctx| {
mixer.run_query(prompts_query(&text), ctx);
});
}
}
fn prompts_query(text: &str) -> Query {
Query {
text: text.to_owned(),
filters: HashSet::from([QueryFilter::PromptHistory]),
}
}
impl Entity for CloudModeV2HistoryMenuView {
@@ -120,40 +226,16 @@ impl View for CloudModeV2HistoryMenuView {
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let row_count = self.inner.as_ref(app).result_count(app);
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let border_color = internal_colors::neutral_4(theme);
let background = internal_colors::neutral_1(theme);
let item_height = appearance.monospace_font_size() + 8.;
let visible_row_count = row_count.max(1) as f32;
let content_height = (item_height * visible_row_count
+ 2. * inline_menu_styles::CONTENT_VERTICAL_PADDING)
.min(MENU_MAX_HEIGHT);
let content: Box<dyn Element> = if row_count == 0 {
let no_results_text = Text::new(
"No results".to_string(),
appearance.ui_font_family(),
inline_menu_styles::font_size(appearance),
)
.with_color(
theme
.disabled_text_color(Fill::Solid(background))
.into_solid(),
)
.finish();
Align::new(no_results_text).finish()
} else {
self.inner.as_ref(app).render_results_only(app)
};
let constrained = ConstrainedBox::new(content)
.with_height(content_height)
let menu_with_height = ConstrainedBox::new(ChildView::new(&self.menu_view).finish())
.with_max_height(MENU_MAX_HEIGHT)
.finish();
let padded = Container::new(constrained)
let padded = Container::new(menu_with_height)
.with_padding_top(MENU_VERTICAL_PADDING)
.with_padding_bottom(MENU_VERTICAL_PADDING)
.finish();
+24 -32
View File
@@ -1,37 +1,30 @@
use std::sync::Arc;
use crate::{
ai::{
llms::{is_using_api_key_for_provider, LLMPreferences},
AIRequestUsageModel, BuyCreditsBannerDisplayState,
},
appearance::Appearance,
settings::{AISettings, InputSettings},
terminal::{
buy_credits_banner::BuyCreditsBanner,
input::{Input, InputAction, InputSuggestionsMode, MenuPositioning},
model::TerminalModel,
view::{TerminalAction, PADDING_LEFT},
},
ui_components::icons::Icon,
workspaces::user_workspaces::UserWorkspaces,
};
use galaxy_completer::completer::Description;
use galaxy_core::features::FeatureFlag;
use galaxyui::{
elements::{
AnchorPair, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, OffsetPositioning,
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
PositioningAxis, Radius, Shrinkable, Stack, Text, XAxisAnchor,
},
fonts::Weight,
presenter::ChildView,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, EntityId, SingletonEntity, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use vim::vim::{VimMode, VimState};
use galaxy_completer::completer::Description;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
AnchorPair, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, Element, EventHandler, Flex, OffsetPositioning, OffsetType, ParentAnchor,
ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds, PositioningAxis, Radius,
Shrinkable, Stack, Text, XAxisAnchor,
};
use galaxyui::fonts::Weight;
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{AppContext, EntityId, SingletonEntity, ViewHandle};
use crate::ai::llms::{is_using_api_key_for_provider, LLMPreferences};
use crate::ai::{AIRequestUsageModel, BuyCreditsBannerDisplayState};
use crate::appearance::Appearance;
use crate::settings::{AISettings, InputSettings};
use crate::terminal::buy_credits_banner::BuyCreditsBanner;
use crate::terminal::input::{Input, InputAction, InputSuggestionsMode, MenuPositioning};
use crate::terminal::model::TerminalModel;
use crate::terminal::view::{TerminalAction, PADDING_LEFT};
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
/// Whether the terminal input message bar should be shown.
///
@@ -210,7 +203,7 @@ pub(super) fn add_voltron_overlay(
);
}
/// Renders the appropriate input suggestions overlay over the input, bsaed on the current input
/// Renders the appropriate input suggestions overlay over the input, based on the current input
/// suggestions mode (if any).
pub(super) fn add_input_suggestions_overlays(
input: &Input,
@@ -524,7 +517,6 @@ fn add_buy_credits_banner_overlay(
buy_credits_banner: &ViewHandle<BuyCreditsBanner>,
is_input_at_top: bool,
) {
use pathfinder_geometry::vector::vec2f;
let (parent_anchor, child_anchor, y_offset) = if is_input_at_top {
(ParentAnchor::BottomLeft, ChildAnchor::TopLeft, 8.)
@@ -3,15 +3,20 @@
use galaxyui::{AppContext, Entity, ModelHandle};
use itertools::Itertools;
use ordered_float::OrderedFloat;
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity};
use crate::ai::agent_conversations_model::{
AgentConversationEntry, AgentConversationEntryId, AgentManagementFilters,
};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::search::data_source::{Query, QueryFilter, QueryResult};
use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::SyncDataSource;
use crate::terminal::input::conversations::search_item::ConversationSearchItem;
use crate::terminal::input::conversations::AcceptConversation;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::workspace::RestoreConversationLayout;
use crate::AgentConversationsModel;
pub struct ConversationMenuDataSource {
agent_view_controller: ModelHandle<AgentViewController>,
@@ -28,6 +33,16 @@ impl ConversationMenuDataSource {
active_session,
}
}
fn entries(&self, app: &AppContext) -> Vec<AgentConversationEntry> {
AgentConversationsModel::as_ref(app)
.get_entries(&AgentManagementFilters::default(), app)
.into_iter()
.filter(|entry: &AgentConversationEntry| {
entry.has_open_action(Some(RestoreConversationLayout::ActivePane), app)
})
.collect()
}
}
impl SyncDataSource for ConversationMenuDataSource {
@@ -38,14 +53,14 @@ impl SyncDataSource for ConversationMenuDataSource {
query: &Query,
app: &AppContext,
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let conversation_navigation_data = ConversationNavigationData::all_conversations(app);
let conversation_entries = self.entries(app);
let query_text = query.text.trim().to_lowercase();
let active_conversation_id = self
let active_item_id = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id();
.active_conversation_id()
.map(AgentConversationEntryId::Conversation);
let filter_by_cwd = query
.filters
@@ -63,16 +78,17 @@ impl SyncDataSource for ConversationMenuDataSource {
// whose most recent directory (falling back to initial directory) matches
// the session's current working directory. If we can't determine the
// session CWD, leave the results unfiltered.
let matches_directory = |data: &ConversationNavigationData| -> bool {
let matches_directory = |entry: &AgentConversationEntry| -> bool {
if !filter_by_cwd {
return true;
}
let Some(session_pwd) = session_pwd.as_deref() else {
return true;
};
data.latest_working_directory
entry
.display
.working_directory
.as_deref()
.or(data.initial_working_directory.as_deref())
.is_some_and(|dir| {
dir.trim_end_matches(std::path::MAIN_SEPARATOR)
== session_pwd.trim_end_matches(std::path::MAIN_SEPARATOR)
@@ -85,31 +101,31 @@ impl SyncDataSource for ConversationMenuDataSource {
// In the zero state, sort conversations in the active pane above all other conversations.
// Within each segment, sort to reverse chronological order.
Ok(conversation_navigation_data
Ok(conversation_entries
.into_iter()
// Don't show the currently open conversation, that's redundant.
.filter(|data| Some(data.id()) != active_conversation_id)
.filter(|data| matches_directory(data))
.sorted_by(|a, b| b.last_updated.cmp(&a.last_updated))
.filter(|entry| Some(entry.id) != active_item_id)
.filter(|entry| matches_directory(entry))
.sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated))
.take(DEFAULT_RESULT_COUNT)
.map(|navigation_data| {
QueryResult::from(ConversationSearchItem::new(navigation_data, app))
.map(|conversation_entry| {
QueryResult::from(ConversationSearchItem::new(conversation_entry))
})
.rev()
.collect())
} else {
let mut search_results = conversation_navigation_data
let mut search_results = conversation_entries
.into_iter()
.filter_map(|navigation_data| {
if Some(navigation_data.id()) == active_conversation_id {
.filter_map(|entry| {
if Some(entry.id) == active_item_id {
// Don't show the currently open conversation, that's redundant.
return None;
}
if !matches_directory(&navigation_data) {
if !matches_directory(&entry) {
return None;
}
let match_result = fuzzy_match::match_indices_case_insensitive(
&navigation_data.title,
&entry.display.title,
&query_text,
)?;
@@ -119,7 +135,7 @@ impl SyncDataSource for ConversationMenuDataSource {
}
Some(QueryResult::from(
ConversationSearchItem::new(navigation_data, app)
ConversationSearchItem::new(entry)
.with_name_match_result(Some(match_result.clone()))
.with_score(OrderedFloat(match_result.score as f64)),
))
+9 -13
View File
@@ -4,14 +4,14 @@ mod data_source;
mod search_item;
mod view;
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{keymap::Keystroke, SingletonEntity};
use pathfinder_color::ColorU;
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
use galaxy_core::ui::appearance::Appearance;
use galaxyui::keymap::Keystroke;
use galaxyui::SingletonEntity;
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::ai::agent_conversations_model::AgentConversationEntryId;
use crate::terminal::input::inline_menu::{
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
InlineMenuType,
@@ -30,7 +30,7 @@ pub enum InlineConversationMenuTab {
/// Action emitted when enter is hit on a conversation the inline conversation menu.
#[derive(Clone, Debug)]
pub struct AcceptConversation {
pub navigation_data: ConversationNavigationData,
pub item_id: AgentConversationEntryId,
}
impl InlineMenuAction for AcceptConversation {
@@ -45,11 +45,9 @@ impl InlineMenuAction for AcceptConversation {
let mut items = Vec::new();
if let Some(item) = inline_menu_model.selected_item() {
let data = &item.navigation_data;
let active_ids =
ActiveAgentViewsModel::as_ref(app).get_all_active_conversation_ids(app);
let is_active = active_ids.contains(&ConversationOrTaskId::ConversationId(data.id));
let is_active = active_ids.contains(&ConversationOrTaskId::from(item.item_id));
let text = if is_active {
" go to conversation"
@@ -57,7 +55,7 @@ impl InlineMenuAction for AcceptConversation {
" continue in this pane"
};
let navigation_data = data.clone();
let item_id = item.item_id;
items.push(MessageItem::clickable(
vec![
MessageItem::keystroke(Keystroke {
@@ -68,9 +66,7 @@ impl InlineMenuAction for AcceptConversation {
],
move |ctx| {
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
item: AcceptConversation {
navigation_data: navigation_data.clone(),
},
item: AcceptConversation { item_id },
cmd_or_ctrl_enter: false,
});
},
@@ -12,11 +12,9 @@ use galaxyui::text_layout::ClipConfig;
use galaxyui::{AppContext, Element, SingletonEntity};
use ordered_float::OrderedFloat;
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent_conversations_model::AgentConversationEntry;
use crate::ai::conversation_status_ui::render_status_element;
use crate::appearance::Appearance;
use crate::search::{ItemHighlightState, SearchItem};
use crate::terminal::input::conversations::AcceptConversation;
@@ -26,24 +24,17 @@ use crate::util::time_format::format_approx_duration_from_now_utc;
/// Search item for rendering a conversation in the inline conversation menu.
#[derive(Debug, Clone)]
pub(super) struct ConversationSearchItem {
navigation_data: ConversationNavigationData,
entry: AgentConversationEntry,
name_match_result: Option<FuzzyMatchResult>,
score: OrderedFloat<f64>,
conversation_status: Option<ConversationStatus>,
}
impl ConversationSearchItem {
pub fn new(navigation_data: ConversationNavigationData, app: &AppContext) -> Self {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation_status = history_model
.conversation(&navigation_data.id)
.map(|conversation| conversation.status().clone());
pub fn new(entry: AgentConversationEntry) -> Self {
Self {
navigation_data,
entry,
name_match_result: None,
score: OrderedFloat(f64::MIN),
conversation_status,
}
}
@@ -67,26 +58,7 @@ impl SearchItem for ConversationSearchItem {
appearance: &Appearance,
) -> Box<dyn Element> {
let icon_size = inline_styles::font_size(appearance);
let icon = match &self.conversation_status {
Some(conversation_status) => {
render_status_element(conversation_status, icon_size, appearance)
}
None => {
let icon_color = appearance
.theme()
.sub_text_color(appearance.theme().background());
Container::new(
ConstrainedBox::new(Icon::History.to_galaxyui_icon(icon_color).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
.with_uniform_padding(STATUS_ELEMENT_PADDING)
.with_background(coloru_with_opacity(icon_color.into(), 10))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
};
let icon = render_status_element(&self.entry.display.status, icon_size, appearance);
Container::new(icon)
.with_margin_right(inline_styles::ICON_MARGIN)
@@ -107,15 +79,18 @@ impl SearchItem for ConversationSearchItem {
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
let secondary_text_color = theme.disabled_text_color(background_color.into());
let open_conversation_ids =
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
let is_active = open_conversation_ids.contains(&ConversationOrTaskId::ConversationId(
self.navigation_data.id,
));
let active_agent_views = ActiveAgentViewsModel::as_ref(app);
let open_terminal_view_id =
active_agent_views.get_terminal_view_id_for_entry(&self.entry, app);
let focused_terminal_view_id = app
.windows()
.active_window()
.and_then(|window_id| active_agent_views.get_focused_terminal_view_id(window_id));
let secondary_suffix = " open in different pane";
let title = &self.navigation_data.title;
let should_show_suffix = is_active && !self.navigation_data.is_in_active_pane;
let title = &self.entry.display.title;
let should_show_suffix = open_terminal_view_id
.is_some_and(|terminal_view_id| Some(terminal_view_id) != focused_terminal_view_id);
let full_text = if should_show_suffix {
format!("{title}{secondary_suffix}")
} else {
@@ -157,7 +132,7 @@ impl SearchItem for ConversationSearchItem {
// We want the timestamp 'column' to have fixed width so clipping is consistent,
// limit the timestamp width to about 10 chars.
let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(self.navigation_data.last_updated.to_utc()),
format_approx_duration_from_now_utc(self.entry.display.last_updated),
appearance.ui_font_family(),
font_size,
)
@@ -191,7 +166,7 @@ impl SearchItem for ConversationSearchItem {
fn accept_result(&self) -> Self::Action {
AcceptConversation {
navigation_data: self.navigation_data.clone(),
item_id: self.entry.id,
}
}
@@ -200,6 +175,6 @@ impl SearchItem for ConversationSearchItem {
}
fn accessibility_label(&self) -> String {
format!("Conversation: {}", self.navigation_data.title)
format!("Conversation: {}", self.entry.display.title)
}
}
+3 -5
View File
@@ -7,8 +7,8 @@ use galaxyui::elements::ChildView;
use galaxyui::{Element, Entity, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent_conversations_model::AgentConversationEntryId;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::conversation_navigation::ConversationNavigationData;
use crate::features::FeatureFlag;
use crate::search::data_source::{Query, QueryFilter};
use crate::search::mixer::SearchMixer;
@@ -27,9 +27,7 @@ use crate::terminal::model::session::active_session::ActiveSession;
#[derive(Debug, Clone)]
pub enum InlineConversationMenuEvent {
/// User 'accepted' a conversation (hit enter).
NavigateToConversation {
conversation_navigation_data: Box<ConversationNavigationData>,
},
NavigateToConversation { item_id: AgentConversationEntryId },
/// User dismissed the menu (escape or click).
Dismissed,
}
@@ -104,7 +102,7 @@ impl InlineConversationMenuView {
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
InlineMenuEvent::AcceptedItem { item, .. } => {
ctx.emit(InlineConversationMenuEvent::NavigateToConversation {
conversation_navigation_data: Box::new(item.navigation_data.clone()),
item_id: item.item_id,
});
}
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => (),
+10 -14
View File
@@ -1,27 +1,23 @@
//! Warp input editor logic related to decorating the input's text, such as
//! applying syntax highlighting and error underlining.
use std::{collections::HashMap, ops::Range};
use std::collections::HashMap;
use std::ops::Range;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, SingletonEntity, ViewContext};
use settings::Setting as _;
use string_offset::{ByteOffset, CharOffset};
use crate::{
appearance::Appearance,
completer::{EmptyCompletionContext, SessionContext},
editor::TextStyleOperation,
settings::InputSettings,
themes::theme::{AnsiColorIdentifier, AnsiColors},
};
pub use galaxy_completer::completer::SuggestionTypeName;
pub use galaxy_completer::util::parse_current_commands_and_tokens;
pub use galaxy_completer::{ParsedTokenData, ParsedTokensSnapshot};
use super::Input;
pub use galaxy_completer::{
completer::SuggestionTypeName, util::parse_current_commands_and_tokens, ParsedTokenData,
ParsedTokensSnapshot,
};
use crate::appearance::Appearance;
use crate::completer::{EmptyCompletionContext, SessionContext};
use crate::editor::TextStyleOperation;
use crate::settings::InputSettings;
use crate::themes::theme::{AnsiColorIdentifier, AnsiColors};
/// Options to enable/disable command decoration and/or AI input background tasks spawned on input
/// edits.
+11 -17
View File
@@ -1,20 +1,14 @@
use galaxyui::{text_layout::TextStyle, App};
use crate::{
appearance::Appearance,
terminal::{
input::{
decorations::InputBackgroundJobOptions,
tests::{
add_window_with_bootstrapped_terminal, initialize_app,
simulate_directory_for_completion,
},
},
model::session::SessionInfo,
},
themes::theme::AnsiColorIdentifier,
};
use galaxy_completer::completer::SuggestionTypeName;
use galaxyui::text_layout::TextStyle;
use galaxyui::App;
use crate::appearance::Appearance;
use crate::terminal::input::decorations::InputBackgroundJobOptions;
use crate::terminal::input::tests::{
add_window_with_bootstrapped_terminal, initialize_app, simulate_directory_for_completion,
};
use crate::terminal::model::session::SessionInfo;
use crate::themes::theme::AnsiColorIdentifier;
#[test]
fn test_decorations_with_multibyte_chars() {
@@ -69,7 +63,7 @@ fn test_decorations_with_multibyte_chars() {
let future_handle = input
.decorations_future_handle
.take()
.expect("should have spanwed decoration task");
.expect("should have spawned decoration task");
ctx.await_spawned_future(future_handle.future_id())
})
.await;
+104
View File
@@ -0,0 +1,104 @@
//! Tracks the `&` prefix mode drafting state in the local input while the user
//! writes a cloud handoff prompt, before a cloud pane/model exists.
use warpui::{Entity, ModelContext};
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
use crate::server::ids::SyncId;
#[derive(Clone)]
pub enum HandoffComposeStateEvent {
ActiveChanged,
EnvironmentSelected,
}
/// Transient state owned by the local input while drafting a cloud handoff
/// prompt (the `&` prefix mode), before a cloud pane exists.
#[derive(Default)]
pub struct HandoffComposeState {
active: bool,
selected_environment_id: Option<SyncId>,
has_explicit_environment_selection: bool,
entry_point: HandoffEntryPoint,
}
impl HandoffComposeState {
pub(crate) fn is_active(&self) -> bool {
self.active
}
pub(crate) fn activate(
&mut self,
entry_point: HandoffEntryPoint,
ctx: &mut ModelContext<Self>,
) {
self.active = true;
self.has_explicit_environment_selection = false;
self.entry_point = entry_point;
ctx.emit(HandoffComposeStateEvent::ActiveChanged);
}
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub(crate) fn entry_point(&self) -> HandoffEntryPoint {
self.entry_point
}
pub(crate) fn exit(&mut self, ctx: &mut ModelContext<Self>) {
if !self.active && !self.has_explicit_environment_selection {
return;
}
self.active = false;
self.has_explicit_environment_selection = false;
ctx.emit(HandoffComposeStateEvent::ActiveChanged);
}
pub(crate) fn selected_environment_id(&self) -> Option<&SyncId> {
self.selected_environment_id.as_ref()
}
pub(crate) fn set_environment_id(
&mut self,
environment_id: Option<SyncId>,
is_explicit: bool,
ctx: &mut ModelContext<Self>,
) {
// Async/implicit updates (e.g. pwd-based overlap resolution) must not
// overwrite an environment the user already picked explicitly.
if !is_explicit && self.has_explicit_environment_selection {
return;
}
// No-op when the value is unchanged, unless this is the first explicit
// selection (which needs to promote `has_explicit_environment_selection`).
if self.selected_environment_id == environment_id
&& (!is_explicit || self.has_explicit_environment_selection)
{
return;
}
self.selected_environment_id = environment_id;
if is_explicit {
self.has_explicit_environment_selection = true;
}
ctx.emit(HandoffComposeStateEvent::EnvironmentSelected);
}
pub(crate) fn ensure_default_environment_id(
&mut self,
environment_id: SyncId,
ctx: &mut ModelContext<Self>,
) {
if self.selected_environment_id.is_none() {
self.set_environment_id(Some(environment_id), false, ctx);
}
}
}
impl Entity for HandoffComposeState {
type Event = HandoffComposeStateEvent;
}
#[cfg(test)]
#[path = "handoff_compose_tests.rs"]
mod tests;
@@ -0,0 +1,37 @@
use warpui::App;
use super::HandoffComposeState;
use crate::ai::ambient_agents::telemetry::HandoffEntryPoint;
use crate::server::ids::{ClientId, SyncId};
#[test]
fn preserves_explicit_environment_selection() {
App::test((), |mut app| async move {
let state = app.add_model(|_| HandoffComposeState::default());
let default_environment_id = SyncId::ClientId(ClientId::new());
let explicit_environment_id = SyncId::ClientId(ClientId::new());
state.update(&mut app, |state, ctx| {
state.activate(HandoffEntryPoint::Ampersand, ctx);
state.ensure_default_environment_id(default_environment_id, ctx);
});
state.read(&app, |state, _| {
assert_eq!(
state.selected_environment_id(),
Some(&default_environment_id)
);
});
// Explicit selection should stick even when ensure_default tries to overwrite.
state.update(&mut app, |state, ctx| {
state.set_environment_id(Some(explicit_environment_id), true, ctx);
state.ensure_default_environment_id(default_environment_id, ctx);
});
state.read(&app, |state, _| {
assert_eq!(
state.selected_environment_id(),
Some(&explicit_environment_id)
);
});
});
}
@@ -6,6 +6,11 @@
//! - Commands are deduplicated, keeping the most recent occurrence
//! - The result is that current session items appear at the bottom (closer to input)
use chrono::{DateTime, Local};
use fuzzy_match::FuzzyMatchResult;
use ordered_float::OrderedFloat;
use warpui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::BlocklistAIHistoryModel;
@@ -13,18 +18,13 @@ use crate::input_suggestions::{HistoryInputSuggestion, HistoryOrder};
use crate::search::data_source::{Query, QueryFilter, QueryResult};
use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::SyncDataSource;
use crate::terminal::history::UpArrowHistoryConfig;
use crate::terminal::history::{History, LinkedWorkflowData};
use crate::terminal::history::{History, LinkedWorkflowData, UpArrowHistoryConfig};
use crate::terminal::input::inline_history::search_item::InlineHistoryItem;
use crate::terminal::input::inline_menu::{
InlineMenuAction, InlineMenuClickBehavior, InlineMenuType,
};
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::SessionId;
use chrono::{DateTime, Local};
use fuzzy_match::FuzzyMatchResult;
use galaxyui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
use ordered_float::OrderedFloat;
#[derive(Clone, Debug)]
pub enum AcceptHistoryItem {
@@ -155,7 +155,7 @@ impl InlineHistoryMenuDataSource {
let mut conversation_entries: Vec<MenuEntry> = Vec::new();
let history_model = BlocklistAIHistoryModel::handle(app).as_ref(app);
for conversation in
history_model.all_live_conversations_for_terminal_view(self.terminal_view_id)
history_model.all_live_conversations_for_terminal_surface(self.terminal_view_id)
{
if conversation.is_entirely_passive() || conversation.exchange_count() == 0 {
continue;
@@ -1,8 +1,7 @@
use chrono::{Local, TimeZone as _};
use crate::input_suggestions::HistoryOrder;
use super::{interleave_conversations, MenuEntry, MenuItem};
use crate::input_suggestions::HistoryOrder;
#[test]
fn interleave_conversations_only_inserts_into_current_session_segment() {
+1 -1
View File
@@ -6,5 +6,5 @@ mod data_source;
mod search_item;
mod view;
pub use data_source::AcceptHistoryItem;
pub use data_source::{AcceptHistoryItem, InlineHistoryMenuDataSource};
pub use view::{HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView};
@@ -1,11 +1,3 @@
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::appearance::Appearance;
use crate::search::{ItemHighlightState, SearchItem};
use crate::terminal::history::LinkedWorkflowData;
use crate::terminal::input::inline_history::data_source::AcceptHistoryItem;
use crate::terminal::input::inline_menu::styles as inline_styles;
use crate::util::time_format::format_approx_duration_from_now_utc;
use chrono::{DateTime, Local};
use fuzzy_match::FuzzyMatchResult;
use galaxy_core::ui::color::coloru_with_opacity;
@@ -19,6 +11,15 @@ use galaxyui::text_layout::ClipConfig;
use galaxyui::{AppContext, Element, SingletonEntity};
use ordered_float::OrderedFloat;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
use crate::appearance::Appearance;
use crate::search::{ItemHighlightState, SearchItem};
use crate::terminal::history::LinkedWorkflowData;
use crate::terminal::input::inline_history::data_source::AcceptHistoryItem;
use crate::terminal::input::inline_menu::styles as inline_styles;
use crate::util::time_format::format_approx_duration_from_now_utc;
#[derive(Debug, Clone)]
pub struct InlineHistoryItem {
item_type: HistoryItemType,
+25 -1
View File
@@ -15,7 +15,7 @@ use crate::search::data_source::{Query, QueryFilter};
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
use crate::settings_view::SettingsSection;
use crate::terminal::history::LinkedWorkflowData;
use crate::terminal::input::buffer_model::InputBufferModel;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::inline_history::data_source::{
AcceptHistoryItem, InlineHistoryMenuDataSource,
};
@@ -162,6 +162,7 @@ pub struct InlineHistoryMenuView {
buffer_model: ModelHandle<InputBufferModel>,
pending_tab_switch_selection: Option<HistoryItemIdentity>,
caller_supplied_tabs: bool,
pending_initial_buffer_sync: bool,
}
impl InlineHistoryMenuView {
@@ -311,6 +312,24 @@ impl InlineHistoryMenuView {
}
});
let suggestions_mode_model_for_buffer = input_suggestions_model.clone();
ctx.subscribe_to_model(
&buffer_model,
move |me, _, _: &InputBufferUpdateEvent, ctx| {
if !suggestions_mode_model_for_buffer
.as_ref(ctx)
.is_inline_history_menu()
{
return;
}
if !me.pending_initial_buffer_sync {
return;
}
me.pending_initial_buffer_sync = false;
me.open_with_current_buffer(ctx);
},
);
let suggestions_mode_model = input_suggestions_model.clone();
ctx.subscribe_to_model(
&agent_view_controller,
@@ -425,6 +444,7 @@ impl InlineHistoryMenuView {
buffer_model,
pending_tab_switch_selection: None,
caller_supplied_tabs,
pending_initial_buffer_sync: false,
}
}
@@ -472,6 +492,10 @@ impl InlineHistoryMenuView {
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
}
pub fn arm_initial_buffer_sync(&mut self) {
self.pending_initial_buffer_sync = true;
}
fn open_with_current_buffer(&mut self, ctx: &mut ViewContext<Self>) {
let query_text = self.buffer_model.as_ref(ctx).current_value().to_owned();
let filters = self.model.as_ref(ctx).active_tab_filters();
@@ -1,7 +1,8 @@
use std::marker::PhantomData;
use std::sync::LazyLock;
use galaxyui::{keymap::Keystroke, AppContext};
use galaxyui::keymap::Keystroke;
use galaxyui::AppContext;
use crate::editor::{SELECT_DOWN_ACTION_NAME, SELECT_UP_ACTION_NAME};
use crate::terminal::input::inline_menu::{
+4 -4
View File
@@ -6,18 +6,18 @@ pub(crate) mod positioning;
pub mod styles;
mod view;
use super::{InputSuggestionsMode, UserQueryMenuAction};
use serde::{Deserialize, Serialize};
pub use message_bar::{InlineMenuMessageArgs, InlineMenuMessageBarArgs};
pub use message_provider::{default_navigation_message_items, InlineMenuMessageProvider};
pub use model::{InlineMenuModel, InlineMenuModelEvent, InlineMenuTabConfig};
pub use positioning::InlineMenuPositioner;
use serde::{Deserialize, Serialize};
pub use view::{
DetailsRenderConfig, InlineMenuAction, InlineMenuClickBehavior, InlineMenuEvent,
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView,
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView, QueryResultRendererExt,
};
use super::{InputSuggestionsMode, UserQueryMenuAction};
/// Identifies a specific inline menu type.
#[derive(
Debug,
@@ -3,6 +3,7 @@ use galaxyui::elements::MouseStateHandle;
use galaxyui::{Entity, ModelContext};
use std::collections::HashSet;
use crate::search::data_source::QueryFilter;
use crate::terminal::input::inline_menu::view::InlineMenuAction;
@@ -1,32 +1,22 @@
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
use galaxy_core::features::FeatureFlag;
use galaxyui::{
units::{IntoPixels, Pixels},
AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId,
};
use settings::Setting as _;
use std::collections::HashMap;
use crate::settings::InputSettings;
use crate::terminal::input::{
inline_menu::{
message_bar::INLINE_MENU_BORDER_WIDTH,
styles::{CONTENT_BORDER_WIDTH, CONTENT_VERTICAL_PADDING},
view::QUERY_RESULT_RENDERER_STYLES,
InlineMenuType,
},
message_bar::common::standard_message_bar_height,
};
use settings::Setting as _;
use galaxy_core::features::FeatureFlag;
use galaxyui::units::{IntoPixels, Pixels};
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId};
use crate::{
ai::blocklist::agent_view::AgentViewController,
appearance::Appearance,
settings::InputModeSettings,
terminal::{
block_list_viewport::InputMode, element_size_at_last_frame,
input::suggestions_mode_model::InputSuggestionsModeModel, SizeInfo,
},
};
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::appearance::Appearance;
use crate::settings::{InputModeSettings, InputSettings};
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::input::inline_menu::message_bar::INLINE_MENU_BORDER_WIDTH;
use crate::terminal::input::inline_menu::styles::{CONTENT_BORDER_WIDTH, CONTENT_VERTICAL_PADDING};
use crate::terminal::input::inline_menu::view::QUERY_RESULT_RENDERER_STYLES;
use crate::terminal::input::inline_menu::InlineMenuType;
use crate::terminal::input::message_bar::common::standard_message_bar_height;
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
use crate::terminal::{element_size_at_last_frame, SizeInfo};
const DEFAULT_VISIBLE_RESULT_COUNT: f32 = 9.;
const MIN_VISIBLE_RESULT_COUNT: f32 = 3.;
@@ -75,7 +65,7 @@ impl InlineMenuPositioner {
.inline_menu_custom_content_heights
.value()
.clone();
ctx.subscribe_to_model(suggestions_mode_model, |me, _, ctx| {
ctx.subscribe_to_model(suggestions_mode_model, |me, _, _, ctx| {
let suggestions_mode_model = me.suggestions_mode_model.as_ref(ctx);
if suggestions_mode_model.is_inline_menu_open() {
if me.agent_view_controller.as_ref(ctx).is_active() {
+60 -19
View File
@@ -1,18 +1,21 @@
//! Generic inline menu view for rendering search results with selection and navigation.
use std::sync::LazyLock;
use itertools::Itertools;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon;
use galaxyui::color::ColorU;
use galaxyui::elements::drag_resize::drag_resize_handle;
use galaxyui::elements::{
drag_resize::drag_resize_handle, ChildAnchor, Clipped, DispatchEventResult, DragResizeElement,
DragResizeHandle, EventHandler, Expanded, Hoverable, MainAxisAlignment, MainAxisSize,
MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, ResizeEndFn, Scrollable, ScrollableElement, ScrollbarWidth,
SizeConstraintCondition, SizeConstraintSwitch, Stack, UniformList, UniformListState,
ChildAnchor, Clipped, DispatchEventResult, DragResizeElement, DragResizeHandle, EventHandler,
Expanded, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, ResizeEndFn,
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, SizeConstraintCondition,
SizeConstraintSwitch, Stack, UniformList, UniformListState,
};
use galaxyui::fonts::Weight;
use galaxyui::platform::Cursor;
@@ -23,13 +26,10 @@ use galaxyui::prelude::{
use galaxyui::scene::{Border, CornerRadius, Radius};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{elements::ScrollStateHandle, ModelHandle, View};
use galaxyui::{
Action, AppContext, Element, Entity, SingletonEntity, TypedActionView, ViewContext, ViewHandle,
WeakViewHandle,
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WeakViewHandle,
};
use itertools::Itertools;
use pathfinder_geometry::vector::vec2f;
use crate::ai::blocklist::agent_view::{
agent_view_bg_color, AgentViewController, AgentViewControllerEvent,
@@ -43,10 +43,10 @@ use crate::terminal::input::inline_menu::message_bar::{
InlineMenuMessageBar, InlineMenuMessageBarArgs,
};
use crate::terminal::input::inline_menu::model::{InlineMenuModel, InlineMenuTabConfig};
use crate::terminal::input::inline_menu::styles as inline_styles;
use crate::terminal::input::inline_menu::positioning::Updated as PositionerUpdated;
use crate::terminal::input::inline_menu::{
default_navigation_message_items, positioning::Updated as PositionerUpdated,
InlineMenuMessageArgs, InlineMenuPositioner, InlineMenuType,
default_navigation_message_items, styles as inline_styles, InlineMenuMessageArgs,
InlineMenuPositioner, InlineMenuType,
};
use crate::terminal::input::message_bar::Message;
use crate::terminal::input::suggestions_mode_model::{
@@ -110,15 +110,30 @@ pub(super) static QUERY_RESULT_RENDERER_STYLES: LazyLock<QueryResultRendererStyl
..Default::default()
});
impl<A: InlineMenuAction> QueryResultRenderer<A> {
pub fn render_inline(
pub trait QueryResultRendererExt {
fn render_inline(
&self,
result_index: usize,
is_selected: bool,
app: &AppContext,
) -> Box<dyn Element>;
fn render_inline_with_highlight_state(
&self,
highlight_state: ItemHighlightState,
is_static_separator: bool,
app: &AppContext,
) -> Box<dyn Element>;
}
impl<A: InlineMenuAction> QueryResultRendererExt for QueryResultRenderer<A> {
fn render_inline(
&self,
result_index: usize,
is_selected: bool,
app: &AppContext,
) -> Box<dyn Element> {
use galaxyui::elements::{DispatchEventResult, EventHandler, Hoverable};
use galaxyui::platform::Cursor;
if self.search_result.is_static_separator() {
return self.render_inline_with_highlight_state(ItemHighlightState::Default, true, app);
@@ -310,6 +325,8 @@ pub struct InlineMenuView<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
banner_fn: Option<BannerFn>,
resize_handle: DragResizeHandle,
drag_indicator_mouse_state: MouseStateHandle,
compact_layout: bool,
dismiss_on_row_click: bool,
}
impl<A: InlineMenuAction> InlineMenuView<A> {
@@ -423,6 +440,7 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
let results = me.mixer.as_ref(ctx).results();
let dismiss_on_row_click = me.dismiss_on_row_click;
me.result_renderers = results
.clone()
.into_iter()
@@ -444,6 +462,9 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
}
};
ctx.dispatch_typed_action(action);
if dismiss_on_row_click {
ctx.dispatch_typed_action(InlineMenuRowAction::<A>::Dismiss);
}
},
*QUERY_RESULT_RENDERER_STYLES,
)
@@ -499,6 +520,8 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
banner_fn: None,
resize_handle: drag_resize_handle(),
drag_indicator_mouse_state: MouseStateHandle::default(),
compact_layout: false,
dismiss_on_row_click: false,
}
}
@@ -507,6 +530,16 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
self
}
pub fn with_compact_layout(mut self) -> Self {
self.compact_layout = true;
self
}
pub fn with_dismiss_on_row_click(mut self) -> Self {
self.dismiss_on_row_click = true;
self
}
pub fn with_banner_fn(
mut self,
banner_fn: impl Fn(&AppContext) -> Option<Box<dyn Element>> + 'static,
@@ -847,7 +880,8 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
)
.finish();
Some(header)
// Clip so trailing controls don't paint past the pane in a narrow split pane.
Some(Clipped::new(header).finish())
}
pub fn render_results_only(
@@ -930,8 +964,11 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuView<A, T> {
.positioner
.as_ref(app)
.should_render_results_in_reverse(app);
let horizontal_padding =
*terminal::view::PADDING_LEFT - QUERY_RESULT_RENDERER_STYLES.result_horizontal_padding;
let horizontal_padding = if self.compact_layout {
0.
} else {
*terminal::view::PADDING_LEFT - QUERY_RESULT_RENDERER_STYLES.result_horizontal_padding
};
let results = self.render_results_only(should_reverse, horizontal_padding, app);
if let Some(banner) = self.banner_fn.as_ref().and_then(|f| f(app)) {
@@ -1101,6 +1138,10 @@ impl<A: InlineMenuAction, T: 'static + Send + Sync> View for InlineMenuView<A, T
}
}
if self.compact_layout {
return Clipped::new(content).finish();
}
let aligned_content = if is_rendering_below_input {
content
} else {
+55 -11
View File
@@ -1,17 +1,18 @@
use crate::ai::blocklist::agent_view::agent_view_bg_color;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon;
use galaxyui::elements::{
Border, CacheOption, Clipped, Container, CornerRadius, Element, Hoverable, Image,
ParentElement, Radius,
Border, CacheOption, Clipped, Container, CornerRadius, Element, FormattedTextElement,
Hoverable, Image, ParentElement, Radius, Wrap, WrapFill, DEFAULT_UI_LINE_HEIGHT_RATIO,
};
use galaxyui::platform::Cursor;
use galaxyui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, Text};
use galaxyui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, MainAxisSize, Text};
use galaxyui::ui_components::keyboard_shortcut::keystroke_to_keys;
use galaxyui::{AppContext, SingletonEntity};
use pathfinder_color::ColorU;
use crate::ai::blocklist::agent_view::agent_view_bg_color;
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
use crate::terminal;
use crate::terminal::input::message_bar::{ChipHorizontalAlignment, Message, MessageItem};
@@ -29,7 +30,7 @@ pub fn render_standard_message_bar(
right_element: Option<Box<dyn Element>>,
app: &AppContext,
) -> Box<dyn Element> {
use galaxyui::prelude::{MainAxisAlignment, MainAxisSize};
use galaxyui::prelude::MainAxisAlignment;
let (left_items, right_chips): (Vec<_>, Vec<_>) = message.items.into_iter().partition(|item| {
!matches!(
@@ -81,6 +82,51 @@ pub fn render_standard_message_bar(
.with_height(standard_message_bar_height(app))
.finish()
}
/// Renders a standard message bar variant for inline text and hyperlinks that need to soft-wrap.
/// `render_standard_message_bar` intentionally remains fixed-height and single-line for existing
/// hint/status bars.
pub fn render_wrapping_standard_message_bar(
icon: Icon,
icon_color: ColorU,
text_color: ColorU,
fragments: Vec<FormattedTextFragment>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_size = styles::font_size(app);
let icon = ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
.with_height(font_size)
.with_width(font_size)
.finish();
let text = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(fragments)]),
font_size,
appearance.ui_font_family(),
appearance.monospace_font_family(),
text_color,
Default::default(),
)
.with_line_height_ratio(DEFAULT_UI_LINE_HEIGHT_RATIO)
.with_hyperlink_font_color(theme.accent().into())
.register_default_click_handlers(|url, _ctx, app| {
app.open_url(&url.url);
})
.finish();
Container::new(
Wrap::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(4.)
.with_child(icon)
.with_child(WrapFill::new(0., text).finish())
.finish(),
)
.with_horizontal_padding(*terminal::view::PADDING_LEFT)
.with_vertical_padding(styles::VERTICAL_PADDING)
.finish()
}
pub fn render_standard_message(message: Message, app: &AppContext) -> Box<dyn Element> {
render_message_bar_items(&message.items, app)
@@ -92,7 +138,9 @@ fn render_message_bar_items(items: &[MessageItem], app: &AppContext) -> Box<dyn
let appearance = Appearance::as_ref(app);
let default_font_color = styles::default_font_color(app);
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_constrain_horizontal_bounds_to_parent(true);
for (i, item) in items.iter().enumerate() {
let mut child: Box<dyn Element> = match item {
@@ -468,11 +516,7 @@ pub fn disableable_message_item_color_overrides(
}
pub mod styles {
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{AppContext, SingletonEntity};
use pathfinder_color::ColorU;
use crate::ui_components::blended_colors;
pub fn font_size(app: &AppContext) -> f32 {
let appearance = Appearance::as_ref(app);
+178 -41
View File
@@ -8,18 +8,23 @@ use galaxyui::elements::{
MouseStateHandle, Radius, Text,
};
use galaxyui::fonts::{Properties, Style, Weight};
use galaxyui::platform::Cursor;
use galaxyui::keymap::Keystroke;
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, Entity, EntityId, SingletonEntity as _};
use itertools::Itertools;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use ordered_float::OrderedFloat;
use galaxyui::{AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _};
use super::model_spec_scores::{
render_model_spec_header, render_model_spec_scores, CostRow, CostRowTooltip,
ModelSpecScoresLayout, CUSTOM_MODEL_ROUTER_DESCRIPTION, CUSTOM_MODEL_ROUTER_TITLE,
MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE,
};
use crate::ai::custom_model_routers::is_custom_router_id;
use crate::ai::execution_profiles::model_menu_items::is_auto;
use crate::ai::llms::{
is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo, LLMPreferences, LLMProvider,
LLMSpec,
is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId,
LLMInfo, LLMPreferences, LLMProvider, LLMSpec,
};
use crate::auth::AuthStateProvider;
use crate::features::FeatureFlag;
@@ -29,19 +34,15 @@ use crate::search::result_renderer::ItemHighlightState;
use crate::search::{SearchItem, SyncDataSource};
use crate::settings_view::SettingsSection;
use crate::terminal::input::inline_menu::{
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
default_navigation_message_items, styles as inline_styles, DetailsRenderConfig,
InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
};
use crate::terminal::input::inline_menu::{styles as inline_styles, DetailsRenderConfig};
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::workspace::WorkspaceAction;
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxyui::keymap::Keystroke;
use galaxyui::platform::OperatingSystem;
use super::model_spec_scores::{
render_model_spec_header, render_model_spec_scores, CostRow, ModelSpecScoresLayout,
MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE,
};
const AUTO_BEDROCK_TOOLTIP: &str = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference.";
#[derive(Clone, Debug)]
pub struct AcceptModel {
@@ -130,11 +131,56 @@ fn model_specs_width(app: &AppContext) -> f32 {
pub struct ModelSelectorDataSource {
terminal_view_id: EntityId,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
}
impl ModelSelectorDataSource {
pub fn new(terminal_view_id: EntityId) -> Self {
Self { terminal_view_id }
pub fn new(
terminal_view_id: EntityId,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
) -> Self {
Self {
terminal_view_id,
ambient_agent_view_model,
}
}
/// Returns whether a model should appear in the inline picker.
/// Custom-endpoint models are suppressed in Oz cloud agent panes because
/// they cannot route through Warp's cloud inference infrastructure.
pub(crate) fn include_model_in_picker(is_cloud_pane: bool, is_custom_endpoint: bool) -> bool {
!is_cloud_pane || !is_custom_endpoint
}
fn order_model_choices<'a>(
llm_preferences: &LLMPreferences,
choices: Vec<&'a LLMInfo>,
) -> Vec<&'a LLMInfo> {
let mut auto_choices = Vec::new();
let mut custom_router_choices = Vec::new();
let mut custom_choices = Vec::new();
let mut other_choices = Vec::new();
for llm in choices {
// Check custom router before is_auto because custom router ids contain
// "auto" and would otherwise land in auto_choices.
if is_custom_router_id(llm.id.as_str()) {
custom_router_choices.push(llm);
} else if is_auto(llm) {
auto_choices.push(llm);
} else if llm_preferences.custom_llm_info_for_id(&llm.id).is_some() {
custom_choices.push(llm);
} else {
other_choices.push(llm);
}
}
auto_choices
.into_iter()
.chain(custom_router_choices)
.chain(custom_choices)
.chain(other_choices)
.collect()
}
}
@@ -161,13 +207,25 @@ impl SyncDataSource for ModelSelectorDataSource {
.clone()
};
let choices: Vec<&LLMInfo> = if is_full_terminal {
llm_preferences.get_cli_agent_llm_choices().collect_vec()
let is_cloud_pane = self.ambient_agent_view_model.is_some();
let choices = if is_full_terminal {
llm_preferences
.get_cli_agent_llm_choices(app)
.filter(|llm| {
let is_custom = llm_preferences.custom_llm_info_for_id(&llm.id).is_some();
Self::include_model_in_picker(is_cloud_pane, is_custom)
})
.collect_vec()
} else {
llm_preferences
.get_base_llm_choices_for_agent_mode()
.get_base_llm_choices_for_agent_mode(app)
.filter(|llm| {
let is_custom = llm_preferences.custom_llm_info_for_id(&llm.id).is_some();
Self::include_model_in_picker(is_cloud_pane, is_custom)
})
.collect_vec()
};
let choices = Self::order_model_choices(llm_preferences, choices);
let query_text = query.text.trim().to_lowercase();
@@ -210,13 +268,21 @@ struct ModelSearchItem {
id: LLMId,
provider: LLMProvider,
spec: Option<LLMSpec>,
provider_icon: Option<Icon>,
leading_icon: Icon,
credential_icon: Option<Icon>,
display_text: String,
is_selected: bool,
is_custom_endpoint: bool,
is_custom_router: bool,
/// Source/routing description for custom model routers (from `LLMInfo.description`).
description: Option<String>,
disable_reason: Option<DisableReason>,
is_auto: bool,
is_using_bedrock: bool,
name_match_result: Option<FuzzyMatchResult>,
score: OrderedFloat<f64>,
manage_api_key_mouse_state: MouseStateHandle,
cost_row_tooltip_mouse_state: MouseStateHandle,
reasoning_level: Option<String>,
discount_percentage: Option<f32>,
}
@@ -232,17 +298,44 @@ impl ModelSearchItem {
} else {
llm.disable_reason.clone()
};
let is_custom_endpoint = LLMPreferences::as_ref(app)
.custom_llm_info_for_id(&llm.id)
.is_some();
let is_custom_router = is_custom_router_id(llm.id.as_str());
let is_auto = is_auto(llm);
let is_using_bedrock = should_show_bedrock_icon_for_model(llm, app);
let is_using_api_key =
is_custom_endpoint || is_using_api_key_for_provider(&llm.provider, app);
let leading_icon = if is_using_bedrock {
Icon::Aws
} else if is_custom_router {
Icon::Dataflow
} else {
llm.provider.icon().unwrap_or(Icon::Oz)
};
let credential_icon = if !is_using_bedrock && is_using_api_key {
Some(Icon::Key)
} else {
None
};
Self {
id: llm.id.clone(),
provider: llm.provider.clone(),
spec: llm.spec.clone(),
provider_icon: llm.provider.icon(),
leading_icon,
credential_icon,
display_text: llm.display_name.clone(),
is_selected: &llm.id == active_llm_id,
is_custom_endpoint,
is_custom_router,
description: llm.description.clone(),
disable_reason,
is_auto,
is_using_bedrock,
name_match_result: None,
score: OrderedFloat(f64::MIN),
manage_api_key_mouse_state: Default::default(),
cost_row_tooltip_mouse_state: Default::default(),
reasoning_level: llm.reasoning_level(),
discount_percentage: llm.discount_percentage,
}
@@ -270,11 +363,7 @@ impl SearchItem for ModelSearchItem {
let icon_size = inline_styles::font_size(appearance);
let icon_color = inline_styles::icon_color(appearance);
let icon = self
.provider_icon
.unwrap_or(Icon::Oz)
.to_galaxyui_icon(icon_color)
.finish();
let icon = self.leading_icon.to_galaxyui_icon(icon_color).finish();
Container::new(
ConstrainedBox::new(icon)
@@ -329,14 +418,17 @@ impl SearchItem for ModelSearchItem {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text.finish());
if is_using_api_key_for_provider(&self.provider, app) {
let key_icon =
ConstrainedBox::new(Icon::Key.to_galaxyui_icon(secondary_text_color).finish())
if let Some(icon) = self.credential_icon {
let credential_icon =
ConstrainedBox::new(icon.to_galaxyui_icon(secondary_text_color).finish())
.with_width(font_size)
.with_height(font_size)
.finish();
row = row.with_child(Container::new(key_icon).with_margin_left(6.).finish());
row = row.with_child(
Container::new(credential_icon)
.with_margin_left(6.)
.finish(),
);
}
if self.is_selected {
@@ -379,7 +471,7 @@ impl SearchItem for ModelSearchItem {
if should_show_discount_chip(
self.discount_percentage,
is_using_api_key_for_provider(&self.provider, app),
is_using_api_key_for_provider(&self.provider, app) || self.is_using_bedrock,
) {
let discount_percentage = self.discount_percentage.unwrap_or(0.);
let chip = Container::new(
@@ -412,11 +504,35 @@ impl SearchItem for ModelSearchItem {
}
fn render_details(&self, app: &AppContext) -> Option<Box<dyn Element>> {
use galaxyui::elements::{Flex, ParentElement as _};
let appearance = crate::appearance::Appearance::as_ref(app);
let theme = appearance.theme();
// Custom auto models get an informational blurb instead of spec bars.
if self.is_custom_router {
let header = render_model_spec_header(
CUSTOM_MODEL_ROUTER_TITLE,
CUSTOM_MODEL_ROUTER_DESCRIPTION,
app,
);
let source_text = Text::new(
self.description.as_deref().unwrap_or("").to_string(),
appearance.ui_font_family(),
inline_styles::font_size(appearance),
)
.with_color(theme.disabled_ui_text_color().into())
.finish();
let column = Flex::column()
.with_child(Container::new(header).with_margin_bottom(12.).finish())
.with_child(source_text)
.finish();
return Some(
ConstrainedBox::new(column)
.with_width(model_specs_width(app))
.finish(),
);
}
let (title, description) = if self.reasoning_level.is_some() {
(REASONING_LEVEL_TITLE, REASONING_LEVEL_DESCRIPTION)
} else {
@@ -424,8 +540,15 @@ impl SearchItem for ModelSearchItem {
};
let header = render_model_spec_header(title, description, app);
let is_using_api_key = is_using_api_key_for_provider(&self.provider, app);
let cost_row = if is_using_api_key {
let is_using_api_key =
self.is_custom_endpoint || is_using_api_key_for_provider(&self.provider, app);
let cost_row = if self.is_using_bedrock || is_using_api_key {
let search_query = if self.is_using_bedrock {
"bedrock"
} else {
"api"
}
.to_string();
let manage_button = appearance
.ui_builder()
.button(
@@ -445,15 +568,29 @@ impl SearchItem for ModelSearchItem {
})
.with_cursor(Some(Cursor::PointingHand))
.build()
.on_click(|ctx, _, _| {
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
search_query: "api".to_string(),
search_query: search_query.clone(),
section: Some(SettingsSection::WarpAgent),
});
})
.finish();
CostRow::BilledToApi {
CostRow::BilledToProvider {
label: if self.is_using_bedrock && self.is_auto {
"Inference may use Bedrock"
} else if self.is_using_bedrock {
"Inference via Bedrock"
} else {
"Inference via API key"
},
tooltip: if self.is_using_bedrock && self.is_auto {
Some(CostRowTooltip {
text: AUTO_BEDROCK_TOOLTIP,
mouse_state: self.cost_row_tooltip_mouse_state.clone(),
})
} else {
None
},
manage_button: Container::new(manage_button).finish(),
}
} else {
@@ -493,7 +630,7 @@ impl SearchItem for ModelSearchItem {
// Show a BYOK option when the user's tier supports it and the provider
// is one that accepts user-supplied API keys.
let byok_available = UserWorkspaces::as_ref(app).is_byo_api_key_enabled()
let byok_available = UserWorkspaces::as_ref(app).is_byo_api_key_enabled(app)
&& matches!(
self.provider,
LLMProvider::OpenAI | LLMProvider::Anthropic | LLMProvider::Google
@@ -1,12 +1,15 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{
Border, ConstrainedBox, Container, CornerRadius, Expanded, Flex, MainAxisAlignment,
MainAxisSize, ParentElement as _, Percentage, Radius, Rect, Stack, Text,
Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Expanded, Flex, Hoverable,
Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement as _, ParentOffsetBounds, Percentage, Radius, Rect, Stack, Text,
};
use galaxyui::prelude::{Align, CrossAxisAlignment};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, Element, SingletonEntity as _};
use pathfinder_color::ColorU;
use crate::ai::llms::LLMSpec;
use crate::appearance::Appearance;
@@ -21,9 +24,22 @@ pub const MODEL_SPECS_DESCRIPTION: &str = "Galaxy's benchmarks for how well a mo
pub const REASONING_LEVEL_TITLE: &str = "Reasoning level";
pub const REASONING_LEVEL_DESCRIPTION: &str = "Increased reasoning levels consume more credits and have higher latency, but higher performance for complicated tasks.";
pub const CUSTOM_MODEL_ROUTER_TITLE: &str = "Custom Model Router";
pub const CUSTOM_MODEL_ROUTER_DESCRIPTION: &str = "Routes each request to a concrete model based on your routing rules, rather than using a single fixed model.";
pub enum CostRow {
Bar { value: Option<f32> },
BilledToApi { manage_button: Box<dyn Element> },
Bar {
value: Option<f32>,
},
BilledToProvider {
label: &'static str,
tooltip: Option<CostRowTooltip>,
manage_button: Box<dyn Element>,
},
}
pub struct CostRowTooltip {
pub text: &'static str,
pub mouse_state: MouseStateHandle,
}
pub struct ModelSpecScoresLayout {
@@ -41,6 +57,7 @@ pub fn render_model_spec_scores(
ScoreRowKind::Bar {
value: spec.as_ref().map(|spec| spec.quality),
},
None,
layout.bg_bar_color,
app,
)];
@@ -50,6 +67,7 @@ pub fn render_model_spec_scores(
ScoreRowKind::Bar {
value: spec.as_ref().map(|spec| spec.speed),
},
None,
layout.bg_bar_color,
app,
));
@@ -59,14 +77,23 @@ pub fn render_model_spec_scores(
rows.push(render_score_row(
"Cost",
ScoreRowKind::Bar { value },
None,
layout.bg_bar_color,
app,
));
}
CostRow::BilledToApi { manage_button } => {
CostRow::BilledToProvider {
label,
tooltip,
manage_button,
} => {
rows.push(render_score_row(
"Cost",
ScoreRowKind::BilledToApi { manage_button },
ScoreRowKind::BilledToProvider {
label,
manage_button,
},
tooltip,
layout.bg_bar_color,
app,
));
@@ -80,13 +107,19 @@ pub fn render_model_spec_scores(
}
enum ScoreRowKind {
Bar { value: Option<f32> },
BilledToApi { manage_button: Box<dyn Element> },
Bar {
value: Option<f32>,
},
BilledToProvider {
label: &'static str,
manage_button: Box<dyn Element>,
},
}
fn render_score_row(
name: &str,
kind: ScoreRowKind,
label_tooltip: Option<CostRowTooltip>,
bg_bar_color: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
@@ -101,23 +134,9 @@ fn render_score_row(
appearance.ui_font_family(),
appearance.monospace_font_size(),
) * 8.;
let label = ConstrainedBox::new(
Text::new(
name.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
inline_styles::primary_text_color(
theme,
inline_styles::menu_background_color(app).into(),
)
.into_solid(),
)
.finish(),
)
.with_width(label_width)
.finish();
let label = ConstrainedBox::new(render_row_label(name, label_tooltip, appearance, app))
.with_width(label_width)
.finish();
let bar_height = app.font_cache().line_height(
appearance.monospace_font_size(),
@@ -184,24 +203,16 @@ fn render_score_row(
)
.finish()
}
ScoreRowKind::BilledToApi { manage_button } => Expanded::new(
ScoreRowKind::BilledToProvider {
label,
manage_button,
} => Expanded::new(
1.,
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
Text::new(
"Billed to API".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(theme.disabled_ui_text_color().into())
.finish(),
)
.finish(),
)
.with_child(render_provider_label(label, appearance))
.with_child(manage_button)
.finish(),
)
@@ -216,6 +227,81 @@ fn render_score_row(
.finish()
}
fn render_row_label(
label: &str,
tooltip: Option<CostRowTooltip>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let label = Text::new(
label.to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
inline_styles::primary_text_color(
appearance.theme(),
inline_styles::menu_background_color(app).into(),
)
.into_solid(),
)
.finish();
let Some(tooltip) = tooltip else {
return label;
};
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(label)
.with_child(
Container::new(render_info_tooltip(tooltip, appearance))
.with_margin_left(4.)
.finish(),
)
.finish()
}
fn render_provider_label(label: &'static str, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
Text::new(label.to_string(), appearance.ui_font_family(), 14.)
.with_color(appearance.theme().disabled_ui_text_color().into())
.finish(),
)
.finish()
}
fn render_info_tooltip(tooltip: CostRowTooltip, appearance: &Appearance) -> Box<dyn Element> {
let icon_color = appearance.theme().disabled_ui_text_color();
let ui_builder = appearance.ui_builder();
let tooltip_text = tooltip.text.to_string();
Hoverable::new(tooltip.mouse_state, move |state| {
let info_icon = Container::new(
ConstrainedBox::new(WarpUiIcon::new("bundled/svg/info.svg", icon_color).finish())
.with_width(13.)
.with_height(13.)
.finish(),
)
.finish();
let mut stack = Stack::new().with_child(info_icon);
if state.is_hovered() {
let tooltip = ui_builder.tool_tip(tooltip_text.clone()).build();
stack.add_positioned_child(
tooltip.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., -3.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
);
}
stack.finish()
})
.finish()
}
pub fn render_model_spec_header(
title: &str,
description: &str,
+26 -6
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet;
use std::sync::LazyLock;
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent};
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
@@ -9,7 +11,6 @@ use galaxyui::{
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, View, ViewContext,
ViewHandle,
};
use pathfinder_color::ColorU;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
@@ -28,11 +29,11 @@ use crate::terminal::input::models::data_source::{AcceptModel, ModelSelectorData
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
use crate::view_components::alert::{Alert, AlertConfig};
use crate::workspace::WorkspaceAction;
use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent};
struct ManageDefaultsTheme;
@@ -105,11 +106,18 @@ pub struct InlineModelSelectorView {
/// Controls whether or not we should filter the contents of the menu
/// based on the contents of the input.
filter_results_by_input: bool,
/// True when the selector was opened from the model chip with a pre-existing
/// prompt that we cleared so the input could be used to search models. The
/// prompt is stashed in the suggestions-mode buffer snapshot and restored
/// when the selector closes.
prompt_parked_for_search: bool,
}
impl InlineModelSelectorView {
#[allow(clippy::too_many_arguments)]
pub fn new(
terminal_view_id: EntityId,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
agent_view_controller: ModelHandle<AgentViewController>,
input_buffer_model: &ModelHandle<InputBufferModel>,
@@ -117,7 +125,9 @@ impl InlineModelSelectorView {
positioner: &ModelHandle<InlineMenuPositioner>,
ctx: &mut ViewContext<Self>,
) -> Self {
let data_source = ctx.add_model(|_| ModelSelectorDataSource::new(terminal_view_id));
let data_source = ctx.add_model(|_| {
ModelSelectorDataSource::new(terminal_view_id, ambient_agent_view_model)
});
let tab_configs = TAB_CONFIGS.clone();
let initial_filters = tab_configs
@@ -251,7 +261,8 @@ impl InlineModelSelectorView {
if model.as_ref(ctx).is_inline_model_selector() {
me.rerun_query(ctx);
} else if model.as_ref(ctx).is_closed() {
me.filter_results_by_input = true;
me.set_filter_results_by_input(true);
me.set_prompt_parked_for_search(false);
me.mixer.update(ctx, |mixer, ctx| {
mixer.reset_results(ctx);
});
@@ -331,11 +342,11 @@ impl InlineModelSelectorView {
&BlocklistAIHistoryModel::handle(ctx),
move |me, _, event, ctx| {
if let BlocklistAIHistoryEvent::UpdatedConversationStatus {
terminal_view_id: event_terminal_view_id,
terminal_surface_id: event_terminal_surface_id,
..
} = event
{
if *event_terminal_view_id == terminal_view_id {
if *event_terminal_surface_id == terminal_view_id {
me.menu_view.update(ctx, |_, ctx| ctx.notify());
}
}
@@ -397,6 +408,7 @@ impl InlineModelSelectorView {
terminal_view_id,
selection_before_tab_switch: None,
filter_results_by_input: true,
prompt_parked_for_search: false,
}
}
@@ -450,6 +462,14 @@ impl InlineModelSelectorView {
self.filter_results_by_input = filter;
}
pub fn prompt_parked_for_search(&self) -> bool {
self.prompt_parked_for_search
}
pub fn set_prompt_parked_for_search(&mut self, parked: bool) {
self.prompt_parked_for_search = parked;
}
pub fn set_active_tab(&self, tab: InlineModelSelectorTab, ctx: &mut ViewContext<Self>) {
let index = self
.menu_view
+1 -2
View File
@@ -3,9 +3,8 @@ mod data_source;
mod search_item;
mod view;
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
use ai::document::AIDocumentId;
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
use galaxyui::keymap::Keystroke;
use crate::ai::document::ai_document_model::AIDocumentVersion;
@@ -14,9 +14,9 @@ use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::result_renderer::ItemHighlightState;
use crate::search::{SearchItem, SyncDataSource};
use crate::server::ids::SyncId;
use crate::terminal::input::inline_menu::styles as inline_styles;
use crate::terminal::input::inline_menu::{
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
default_navigation_message_items, styles as inline_styles, InlineMenuAction,
InlineMenuMessageArgs, InlineMenuType,
};
use crate::terminal::input::message_bar::Message;
use crate::workflows::CloudWorkflow;
@@ -113,9 +113,7 @@ impl SyncDataSource for PromptsMenuDataSource {
.collect()
})
.map_err(|e| {
Box::new(DataSourceSearchError {
message: e.to_string(),
}) as DataSourceRunErrorWrapper
Box::new(DataSourceSearchError::new(e.to_string())) as DataSourceRunErrorWrapper
})
}
}
+1 -2
View File
@@ -4,10 +4,9 @@ mod data_source;
mod search_item;
mod view;
pub use view::{InlineReposMenuEvent, InlineReposMenuView};
use std::path::PathBuf;
pub use view::{InlineReposMenuEvent, InlineReposMenuView};
use galaxyui::keymap::Keystroke;
use crate::terminal::input::inline_menu::{
-1
View File
@@ -55,7 +55,6 @@ impl InlineReposMenuView {
},
ctx,
);
mixer.run_query(repos_query(""), ctx);
mixer
});
+1 -1
View File
@@ -116,7 +116,7 @@ impl SyncDataSource for RewindDataSource {
let query_text = exchange
.input
.iter()
.find_map(AIAgentInput::user_query)
.find_map(AIAgentInput::display_query)
.unwrap_or_default();
// Find the end of this "block" - either the next user query or end of exchanges
+13 -14
View File
@@ -1,9 +1,9 @@
use std::path::PathBuf;
use ai::skills::{SkillProvider, SkillReference, SkillScope};
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use ordered_float::OrderedFloat;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::Fill;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Highlight, ParentElement, Shrinkable, Text,
};
@@ -14,7 +14,6 @@ use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _,
};
use ordered_float::OrderedFloat;
use crate::ai::skills::SkillManager;
use crate::appearance::Appearance;
@@ -23,9 +22,9 @@ use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::result_renderer::ItemHighlightState;
use crate::search::{SearchItem, SyncDataSource};
use crate::terminal::cli_agent_sessions::{CLIAgentInputState, CLIAgentSessionsModel};
use crate::terminal::input::inline_menu::styles as inline_styles;
use crate::terminal::input::inline_menu::{
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuType,
default_navigation_message_items, styles as inline_styles, InlineMenuAction,
InlineMenuMessageArgs, InlineMenuType,
};
use crate::terminal::input::message_bar::{Message, MessageItem};
use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent};
@@ -77,7 +76,7 @@ impl SkillSelectorDataSource {
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&active_session, |_, event, ctx| match event {
ctx.subscribe_to_model(&active_session, |_, _, event, ctx| match event {
// Emit event so the mixer can re-run its query with the new pwd
ActiveSessionEvent::UpdatedPwd | ActiveSessionEvent::Bootstrapped => {
ctx.emit(UpdatedAvailableSkills);
@@ -104,12 +103,11 @@ impl SkillSelectorDataSource {
self.include_bundled = include_bundled;
}
/// Get the current working directory from the active session
fn get_current_working_directory(&self, app: &AppContext) -> Option<PathBuf> {
/// Get the current working directory location from the active session.
fn get_current_working_directory(&self, app: &AppContext) -> Option<LocalOrRemotePath> {
self.active_session
.as_ref(app)
.current_working_directory()
.map(PathBuf::from)
.current_working_directory_location(app)
}
}
@@ -123,10 +121,12 @@ impl SyncDataSource for SkillSelectorDataSource {
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
let cwd = self.get_current_working_directory(app);
let cli_agent_providers = self.active_cli_agent_providers(app);
let skills =
SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_deref(), app);
let skills = SkillManager::as_ref(app).get_skills_for_working_directory(cwd.as_ref(), app);
// Filter out bundled skills when in open mode, since they cannot be opened.
// Bundled skills are identified by scope rather than reference: local
// catalog entries are `BundledSkillId`-referenced, but remote catalog
// entries are path-referenced, and both must be excluded here.
// When CLI agent input is open, filter to skills that exist in a supported
// provider folder. We check all paths for the skill name (not just the
// deduplicated provider) because deduplication may pick a higher-priority
@@ -138,8 +138,7 @@ impl SyncDataSource for SkillSelectorDataSource {
if let Some(providers) = &cli_agent_providers {
skill_manager.skill_exists_for_any_provider(skill, providers)
} else {
self.include_bundled
|| !matches!(skill.reference, SkillReference::BundledSkillId(_))
self.include_bundled || skill.scope != SkillScope::Bundled
}
})
.map(|mut skill| {
+1 -2
View File
@@ -1,6 +1,6 @@
use ai::skills::SkillReference;
use galaxyui::elements::ChildView;
use galaxyui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
use galaxyui::{Element, Entity, EntityId, ModelHandle, View, ViewContext, ViewHandle};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::search::data_source::Query;
@@ -14,7 +14,6 @@ use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::model::session::active_session::ActiveSession;
use galaxyui::EntityId;
#[derive(Debug, Clone)]
pub enum InlineSkillSelectorEvent {
+29 -11
View File
@@ -2,15 +2,17 @@ use ai::skills::SkillReference;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use input_classifier::InputType;
use settings::Setting as _;
use crate::ai::blocklist::{BlocklistAIInputEvent, BlocklistAIInputModel};
use crate::ai::blocklist::{
BlocklistAIInputEvent, BlocklistAIInputModel, InputTypeAutoDetectionSource,
};
use crate::ai::skills::SkillManager;
use crate::search::slash_command_menu::StaticCommand;
use crate::settings::InputSettings;
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
use crate::terminal::input::slash_commands::SlashCommandDataSource;
use crate::terminal::model::session::active_session::ActiveSession;
use settings::Setting as _;
/// Event emitted by the slash command model when its entry state is updated.
#[derive(Debug, Clone)]
@@ -131,7 +133,7 @@ impl SlashCommandModel {
data_source: ModelHandle<SlashCommandDataSource>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(buffer_model, |me, event, ctx| {
ctx.subscribe_to_model(buffer_model, |me, _, event, ctx| {
me.handle_input_buffer_update(event, ctx);
});
@@ -140,7 +142,7 @@ impl SlashCommandModel {
//
// In the new modality, slash commands _are_ accessible in the terminal view, which is
// in locked shell mode if NLD is disabled.
ctx.subscribe_to_model(ai_input_model, |me, event, ctx| match event {
ctx.subscribe_to_model(ai_input_model, |me, _, event, ctx| match event {
BlocklistAIInputEvent::InputTypeChanged { config }
| BlocklistAIInputEvent::LockChanged { config } => {
if config.is_locked {
@@ -193,7 +195,11 @@ impl SlashCommandModel {
&& !self.ai_input_model.as_ref(ctx).is_input_type_locked()
{
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(InputType::Shell, ctx);
input_model.set_input_type(
InputType::Shell,
Some(InputTypeAutoDetectionSource::SlashCommand),
ctx,
);
});
}
@@ -242,11 +248,11 @@ impl SlashCommandModel {
let skill_name = possible_command.strip_prefix('/')?;
let cwd = self.active_session.as_ref(ctx).current_working_directory();
let cwd_path = cwd.as_ref().map(std::path::Path::new);
let active_session = self.active_session.as_ref(ctx);
let cwd_path = active_session.current_working_directory_location(ctx);
let skills = SkillManager::handle(ctx)
.as_ref(ctx)
.get_skills_for_working_directory(cwd_path, ctx);
.get_skills_for_working_directory(cwd_path.as_ref(), ctx);
let matched_skill = skills.into_iter().find(|skill| skill.name == skill_name)?;
@@ -332,7 +338,11 @@ impl SlashCommandModel {
// mode, either locked or unlocked; if the input were locked to shell mode then the
// state would be `DisabledUntilEmptyBuffer` and we would have shortcircuited above.
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(InputType::AI, ctx);
input_model.set_input_type(
InputType::AI,
Some(InputTypeAutoDetectionSource::SlashCommand),
ctx,
);
});
}
self.state = SlashCommandEntryState::SlashCommand(detected_command);
@@ -346,7 +356,11 @@ impl SlashCommandModel {
// Skill commands always require AI mode
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(InputType::AI, ctx);
input_model.set_input_type(
InputType::AI,
Some(InputTypeAutoDetectionSource::SlashCommand),
ctx,
);
});
self.state = SlashCommandEntryState::SkillCommand(detected_skill);
}
@@ -373,7 +387,11 @@ impl SlashCommandModel {
// handled appropriately. I am just making this change to preserve the existing
// product behavior (agent icon in NLD toggle becomes yellow).
self.ai_input_model.update(ctx, |input_model, ctx| {
input_model.set_input_type(InputType::AI, ctx);
input_model.set_input_type(
InputType::AI,
Some(InputTypeAutoDetectionSource::SlashCommand),
ctx,
);
});
}
@@ -1,10 +1,13 @@
use settings::Setting as _;
use warpui::{App, SingletonEntity as _};
use super::SlashCommandEntryState;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::{QueuedQuery, QueuedQueryModel, QueuedQueryOrigin};
use crate::report_if_error;
use crate::search::slash_command_menu::static_commands::commands;
use crate::settings::AISettings;
use crate::terminal::input::tests::{add_window_with_bootstrapped_terminal, initialize_app};
use galaxyui::{App, SingletonEntity as _};
use settings::Setting as _;
#[test]
fn test_parse_slash_command_handles_argument_rules() {
@@ -459,7 +462,18 @@ fn test_submit_queued_prompt_routes_plain_text_to_conversation() {
// It routes through detect_command (returning None) and falls through
// to send_user_query_in_new_conversation.
input.update(&mut app, |input, ctx| {
input.submit_queued_prompt("fix the tests".to_string(), ctx);
let conversation_id = AIConversationId::new();
let query_id = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.append(
conversation_id,
QueuedQuery::new(
"fix the tests".to_owned(),
QueuedQueryOrigin::QueueSlashCommand,
),
ctx,
)
});
input.submit_queued_prompt("fix the tests".to_string(), conversation_id, query_id, ctx);
});
});
}
@@ -491,7 +505,18 @@ fn test_submit_queued_prompt_detects_slash_command() {
// submit_queued_prompt should detect the slash command and route through
// execute_slash_command. This should not panic.
input.update(&mut app, |input, ctx| {
input.submit_queued_prompt(command_text, ctx);
let conversation_id = AIConversationId::new();
let query_id = QueuedQueryModel::handle(ctx).update(ctx, |model, ctx| {
model.append(
conversation_id,
QueuedQuery::new(
command_text.clone(),
QueuedQueryOrigin::QueueSlashCommand,
),
ctx,
)
});
input.submit_queued_prompt(command_text, conversation_id, query_id, ctx);
});
}
});
File diff suppressed because it is too large Load Diff
@@ -1,55 +1,64 @@
mod saved_prompts;
mod zero_state;
use ai::skills::SkillProvider;
use galaxy_core::features::FeatureFlag;
pub(crate) use saved_prompts::*;
pub use zero_state::*;
use std::collections::HashMap;
use std::path::PathBuf;
use ai::skills::SkillProvider;
use fuzzy_match::FuzzyMatchResult;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::fonts::FamilyId;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use ordered_float::OrderedFloat;
pub(crate) use saved_prompts::*;
#[cfg(not(target_family = "wasm"))]
use galaxy_cli::agent::Harness;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::Icon as WarpIcon;
pub use zero_state::*;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use super::AcceptSlashCommandOrSavedPrompt;
use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::skills::{SkillDescriptor, SkillManager};
use crate::search::data_source::{Query, QueryResult};
use crate::search::mixer::DataSourceRunErrorWrapper;
use crate::search::slash_command_menu::fuzzy_match::SlashCommandFuzzyMatchResult;
use crate::search::slash_command_menu::static_commands::commands::{self, COMMAND_REGISTRY};
use crate::search::slash_command_menu::static_commands::Availability;
use crate::search::slash_command_menu::{SlashCommandId, StaticCommand};
use crate::search::SyncDataSource;
use crate::settings::{
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent, PrivacySettings,
PrivacySettingsChangedEvent,
};
use crate::terminal::cli_agent_sessions::{
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
};
use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent};
use crate::terminal::model::session::SessionType;
use galaxy_core::ui::Icon as GalaxyIcon;
use super::AcceptSlashCommandOrSavedPrompt;
use crate::{
ai::blocklist::{
agent_view::{AgentViewController, AgentViewControllerEvent},
block::cli_controller::{CLISubagentController, CLISubagentEvent},
},
search::{
slash_command_menu::{
static_commands::commands::{self, COMMAND_REGISTRY},
SlashCommandId, StaticCommand,
},
SyncDataSource,
},
settings::{AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent},
terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent},
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
};
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
pub struct DataSourceArgs {
pub active_session: ModelHandle<ActiveSession>,
pub agent_view_controller: ModelHandle<AgentViewController>,
pub cli_subagent_controller: ModelHandle<CLISubagentController>,
pub terminal_view_id: EntityId,
pub ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
}
/// Context needed to decide which slash commands are enabled.
struct ActiveCommandsContext {
session_context: Availability,
is_orchestration_enabled: bool,
is_cloud_handoff_enabled: bool,
#[cfg(not(target_family = "wasm"))]
active_conversation_is_cloud_oz: bool,
has_default_host: bool,
is_cli_agent_input: bool,
}
pub struct SlashCommandDataSource {
@@ -59,22 +68,33 @@ pub struct SlashCommandDataSource {
terminal_view_id: EntityId,
active_commands_by_id: HashMap<SlashCommandId, StaticCommand>,
active_repo_root: Option<PathBuf>,
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
is_cloud_mode_v2: bool,
}
impl SlashCommandDataSource {
pub fn new(args: DataSourceArgs, ctx: &mut ModelContext<Self>) -> Self {
Self::build(args, /* is_cloud_mode_v2 */ false, ctx)
}
pub fn for_cloud_mode_v2(args: DataSourceArgs, ctx: &mut ModelContext<Self>) -> Self {
Self::build(args, /* is_cloud_mode_v2 */ true, ctx)
}
fn build(args: DataSourceArgs, is_cloud_mode_v2: bool, ctx: &mut ModelContext<Self>) -> Self {
let DataSourceArgs {
active_session,
agent_view_controller,
cli_subagent_controller,
terminal_view_id,
ambient_agent_view_model,
} = args;
ctx.subscribe_to_model(&active_session, |me, event, ctx| match event {
ctx.subscribe_to_model(&active_session, |me, _, event, ctx| match event {
ActiveSessionEvent::UpdatedPwd | ActiveSessionEvent::Bootstrapped => {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(&cli_subagent_controller, |me, event, ctx| {
ctx.subscribe_to_model(&cli_subagent_controller, |me, _, event, ctx| {
if let CLISubagentEvent::SpawnedSubagent { .. }
| CLISubagentEvent::FinishedSubagent { .. }
| CLISubagentEvent::UpdatedControl { .. } = event
@@ -82,19 +102,31 @@ impl SlashCommandDataSource {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| match event {
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| match event {
AgentViewControllerEvent::EnteredAgentView { .. }
| AgentViewControllerEvent::ExitedAgentView { .. } => {
me.recompute_active_commands(ctx);
}
_ => (),
});
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, event, ctx| {
if matches!(event, AISettingsChangedEvent::IsAnyAIEnabled { .. }) {
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::IsAnyAIEnabled { .. }
| AISettingsChangedEvent::ShouldForceDisableCloudHandoff { .. }
) {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(&InputSettings::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&PrivacySettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
PrivacySettingsChangedEvent::UpdateIsCloudConversationStorageEnabled { .. }
) {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(&InputSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
InputSettingsChangedEvent::EnableSlashCommandsInTerminal { .. }
@@ -102,14 +134,18 @@ impl SlashCommandDataSource {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| {
if matches!(event, UserWorkspacesEvent::CodebaseContextEnablementChanged) {
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
UserWorkspacesEvent::CodebaseContextEnablementChanged
| UserWorkspacesEvent::TeamsChanged
) {
me.recompute_active_commands(ctx);
}
});
ctx.subscribe_to_model(
&CLIAgentSessionsModel::handle(ctx),
move |me, event, ctx| {
move |me, _, event, ctx| {
if let CLIAgentSessionsModelEvent::InputSessionChanged {
terminal_view_id: event_terminal_view_id,
..
@@ -121,6 +157,34 @@ impl SlashCommandDataSource {
}
},
);
// Recompute when the active conversation switches so commands gated on the active
// conversation's task (e.g. /continue-locally) update on navigation.
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if matches!(
event,
BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
) {
me.recompute_active_commands(ctx);
}
},
);
// Recompute when task data is updated so commands gated on a conversation's task
// harness (e.g. /continue-locally) appear once the task fetch resolves.
ctx.subscribe_to_model(
&AgentConversationsModel::handle(ctx),
|me, _, event, ctx| {
if matches!(
event,
AgentConversationsModelEvent::TasksUpdated
| AgentConversationsModelEvent::NewTasksReceived
) {
me.recompute_active_commands(ctx);
}
},
);
let mut me = Self {
active_session,
@@ -129,6 +193,8 @@ impl SlashCommandDataSource {
terminal_view_id,
active_commands_by_id: Default::default(),
active_repo_root: None,
ambient_agent_view_model,
is_cloud_mode_v2,
};
me.recompute_active_commands(ctx);
me
@@ -139,7 +205,38 @@ impl SlashCommandDataSource {
/// for a running CLI agent (Claude Code, Codex, etc.).
const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills"];
fn is_cloud_mode(&self, ctx: &AppContext) -> bool {
self.is_cloud_mode_v2
|| (FeatureFlag::CloudMode.is_enabled()
&& self
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| model.as_ref(ctx).is_ambient_agent()))
}
fn recompute_active_commands(&mut self, ctx: &mut ModelContext<Self>) {
let active_commands_context = self.active_commands_context(ctx);
let old_active_command_count = self.active_commands_by_id.len();
self.active_commands_by_id = HashMap::from_iter(
COMMAND_REGISTRY
.all_commands_by_id()
.filter(|(_, command)| {
self.command_is_active_in_context(command, &active_commands_context)
})
.map(|(id, command)| (id, command.clone())),
);
// This is an imperfect heuristic, but better than re-firing unnecessarily.
//
// If it actually matters, we can update it.
if self.active_commands_by_id.len() != old_active_command_count {
ctx.emit(UpdatedActiveCommands);
}
}
/// Gather the context needed to check slash command availability.
fn active_commands_context(&self, ctx: &AppContext) -> ActiveCommandsContext {
let is_cli_agent_input = self.is_cli_agent_input_open(ctx);
let mut session_context = Availability::empty();
@@ -197,37 +294,81 @@ impl SlashCommandDataSource {
session_context |= Availability::AI_ENABLED;
}
let is_orchestration_enabled = AISettings::as_ref(ctx).is_orchestration_enabled(ctx);
let old_active_command_count = self.active_commands_by_id.len();
self.active_commands_by_id = HashMap::from_iter(
COMMAND_REGISTRY
.all_commands_by_id()
.filter(|(_, command)| command.is_active(session_context))
.filter(|(_, command)| {
command.name != commands::ORCHESTRATE_NAME || is_orchestration_enabled
})
// The static `/feedback` command is an AI-off fallback for the richer bundled
// `feedback` skill. Hide it whenever the bundled skill will actually take over,
// matching the precedence used by `Workspace::send_feedback`.
.filter(|(_, command)| {
command.name != commands::FEEDBACK.name
|| !crate::workspace::is_feedback_skill_available(ctx)
})
// When CLI agent input is open, restrict to the explicit allowlist.
.filter(|(_, command)| {
!is_cli_agent_input
|| Self::CLI_AGENT_INPUT_ALLOWED_COMMANDS.contains(&command.name)
})
.map(|(id, command)| (id, command.clone())),
);
// This is an imperfect heuristic, but better than re-firing unnecessarily.
//
// If it actually matters, we can update it.
if self.active_commands_by_id.len() != old_active_command_count {
ctx.emit(UpdatedActiveCommands);
if self.is_cloud_mode_v2 && FeatureFlag::CloudModeInputV2.is_enabled() {
session_context |= Availability::CLOUD_MODE_V2_COMPOSER;
}
if self.is_cloud_mode(ctx) {
session_context |= Availability::CLOUD_AGENT;
} else {
session_context |= Availability::NOT_CLOUD_AGENT;
}
// Hide /host when no default host is configured (env var or workspace setting).
let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
.ok()
.filter(|s| !s.is_empty())
.is_some()
|| UserWorkspaces::as_ref(ctx).default_host_slug().is_some();
let ai_settings = AISettings::as_ref(ctx);
ActiveCommandsContext {
session_context,
is_orchestration_enabled: ai_settings.is_orchestration_enabled(ctx),
is_cloud_handoff_enabled: ai_settings.is_cloud_handoff_enabled(ctx),
#[cfg(not(target_family = "wasm"))]
active_conversation_is_cloud_oz: self.active_conversation_is_cloud_oz(ctx),
has_default_host,
is_cli_agent_input,
}
}
fn command_is_active_in_context(
&self,
command: &StaticCommand,
context: &ActiveCommandsContext,
) -> bool {
if !command.is_active(context.session_context) {
return false;
}
if command.name == commands::ORCHESTRATE_NAME && !context.is_orchestration_enabled {
return false;
}
if command.name == commands::MOVE_TO_CLOUD.name && !context.is_cloud_handoff_enabled {
return false;
}
if command.name == commands::FORK.name
&& context
.session_context
.contains(Availability::CLOUD_MODE_V2_COMPOSER)
{
return false;
}
// /continue-locally only applies to cloud Oz conversations. Non-Oz cloud runs
// (Claude, Gemini) are filtered out so the slash menu doesn't surface a no-op command.
#[cfg(not(target_family = "wasm"))]
if command.name == commands::CONTINUE_LOCALLY.name
&& !context.active_conversation_is_cloud_oz
{
return false;
}
// /host is only useful when a default self-hosted host is configured.
if command.name == commands::HOST.name && !context.has_default_host {
return false;
}
// When CLI agent input is open, restrict to the explicit allowlist.
if context.is_cli_agent_input
&& !Self::CLI_AGENT_INPUT_ALLOWED_COMMANDS.contains(&command.name)
{
return false;
}
true
}
pub(crate) fn command_is_active(&self, command: &StaticCommand, ctx: &AppContext) -> bool {
let active_commands_context = self.active_commands_context(ctx);
self.command_is_active_in_context(command, &active_commands_context)
}
/// Update the active repository root for this terminal. Called by the parent when
@@ -251,6 +392,10 @@ impl SlashCommandDataSource {
self.agent_view_controller.as_ref(ctx).is_active()
}
pub fn active_session_for_v2_zero_state(&self) -> &ModelHandle<ActiveSession> {
&self.active_session
}
/// Returns `true` if the CLI agent rich input is currently open for this terminal.
pub fn is_cli_agent_input_open(&self, ctx: &AppContext) -> bool {
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)
@@ -267,6 +412,57 @@ impl SlashCommandDataSource {
.filter(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
.map(|s| s.agent.supported_skill_providers())
}
/// Returns true when the active conversation is associated with a cloud Oz
/// `AmbientAgentTask`. Used to gate `/continue-locally` to runs that can
/// actually be forked into a local Warp conversation.
///
/// Permissive when the harness is not yet known: we consider an absent task or
/// missing `agent_config_snapshot.harness` to be Oz, matching the existing
/// tombstone gate (`conversation_ended_tombstone_view::render_action_buttons`).
/// Only an explicit non-Oz harness (Claude, Gemini, OpenCode, Unknown) hides the
/// command. Conversations without a `task_id` are local and never qualify.
#[cfg(not(target_family = "wasm"))]
fn active_conversation_is_cloud_oz(&self, ctx: &AppContext) -> bool {
let conversation_id = match self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
Some(id) => id,
None => match BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation(self.terminal_view_id)
{
Some(conv) => conv.id(),
None => return false,
},
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(&conversation_id) else {
return false;
};
let Some(task_id) = conversation.task_id() else {
return false;
};
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
// Task data not yet fetched. Permissive default: assume Oz so the command
// is reachable while the fetch is in flight; once the fetch resolves,
// `TasksUpdated` triggers a recompute and a non-Oz task hides the command.
return true;
};
match task
.agent_config_snapshot
.as_ref()
.and_then(|s| s.harness.as_ref())
{
Some(config) => config.harness_type == Harness::Oz,
None => true,
}
}
}
impl SyncDataSource for SlashCommandDataSource {
@@ -309,6 +505,7 @@ impl SyncDataSource for SlashCommandDataSource {
InlineItem::from_slash_command(id, command, app)
.with_name_match_result(fuzzy_result.name_match_result)
.with_description_match_result(fuzzy_result.description_match_result)
.with_compact_layout(self.is_cloud_mode_v2)
.with_score(
OrderedFloat(score) * SCORE_MULTIPLIER
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
@@ -324,11 +521,11 @@ impl SyncDataSource for SlashCommandDataSource {
// Skills are invoked by the agent, so they're hidden entirely when AI is globally off.
if FeatureFlag::ListSkills.is_enabled() && AISettings::as_ref(app).is_any_ai_enabled(app) {
let cli_agent_providers = self.active_cli_agent_providers(app);
let cwd = self.active_session.as_ref(app).current_working_directory();
let cwd_path = cwd.as_ref().map(std::path::Path::new);
let active_session = self.active_session.as_ref(app);
let cwd_path = active_session.current_working_directory_location(app);
let skills = SkillManager::handle(app)
.as_ref(app)
.get_skills_for_working_directory(cwd_path, app);
.get_skills_for_working_directory(cwd_path.as_ref(), app);
let skill_manager = SkillManager::as_ref(app);
for mut skill in skills {
@@ -362,6 +559,7 @@ impl SyncDataSource for SlashCommandDataSource {
InlineItem::from_skill(&skill, app)
.with_name_match_result(fuzzy_result.name_match_result)
.with_description_match_result(fuzzy_result.description_match_result)
.with_compact_layout(self.is_cloud_mode_v2)
.with_score(
OrderedFloat(score) * SCORE_MULTIPLIER
+ OrderedFloat(prefix_boost) * SCORE_MULTIPLIER
@@ -412,6 +610,7 @@ pub struct InlineItem {
pub name_match_result: Option<FuzzyMatchResult>,
pub description_match_result: Option<FuzzyMatchResult>,
pub score: OrderedFloat<f64>,
pub compact_layout: bool,
}
impl InlineItem {
@@ -430,6 +629,27 @@ impl InlineItem {
name_match_result: None,
description_match_result: None,
score: OrderedFloat(f64::MIN),
compact_layout: false,
}
}
pub(crate) fn from_saved_prompt(
saved_prompt: &crate::workflows::CloudWorkflow,
app: &AppContext,
) -> Self {
let appearance = Appearance::as_ref(app);
Self {
action: AcceptSlashCommandOrSavedPrompt::SavedPrompt {
id: saved_prompt.id,
},
icon_path: "bundled/svg/prompt.svg",
name: saved_prompt.model().data.name().to_owned(),
description: None,
font_family: appearance.ui_font_family(),
name_match_result: None,
description_match_result: None,
score: OrderedFloat(f64::MIN),
compact_layout: false,
}
}
@@ -462,6 +682,7 @@ impl InlineItem {
name_match_result: None,
description_match_result: None,
score: OrderedFloat(f64::MIN),
compact_layout: false,
}
}
@@ -479,8 +700,13 @@ impl InlineItem {
self.score = score;
self
}
pub(crate) fn with_compact_layout(mut self, compact: bool) -> Self {
self.compact_layout = compact;
self
}
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;

Some files were not shown because too many files have changed in this diff Show More