first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-4
@@ -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,
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 } => {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-5
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+147
-10
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
)))
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+189
-70
@@ -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
|
||||
|
||||
@@ -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(..)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => (),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -55,7 +55,6 @@ impl InlineReposMenuView {
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer.run_query(repos_query(""), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user