Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
use crate::terminal::model::session::Session;
|
||||
use smol_str::SmolStr;
|
||||
use std::sync::Arc;
|
||||
use warp_completer::parsers::simple::all_parsed_commands;
|
||||
|
||||
/// Contains information about an aliased command.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AliasedCommand {
|
||||
/// The alias itself.
|
||||
pub alias: SmolStr,
|
||||
/// The value that the alias is mapped to.
|
||||
pub alias_value: String,
|
||||
}
|
||||
|
||||
/// Returns whether the alias can be expanded by Warp given its value.
|
||||
///
|
||||
/// We don't expand on any alias that starts with itself, as it leads to
|
||||
/// cases where the alias is expanded twice: once as the user types in the
|
||||
/// editor and again by the shell when the command is entered.
|
||||
// TODO: CORE-240 Don't expand if any command in the alias value is equal
|
||||
// to the alias itself.
|
||||
pub fn is_expandable_alias(alias: &str, alias_value: &str) -> bool {
|
||||
if let Some(command_token) = alias_value.split_whitespace().next() {
|
||||
return alias != command_token;
|
||||
}
|
||||
// If the alias value is empty, we don't expand.
|
||||
false
|
||||
}
|
||||
|
||||
/// Searches the source text for any aliases in a command position. Returns
|
||||
/// information about the alias if found.
|
||||
fn check_for_alias(source: &str, session: Arc<Session>) -> Option<AliasedCommand> {
|
||||
let mut all_commands_iterator =
|
||||
all_parsed_commands(source, session.shell_family().escape_char());
|
||||
all_commands_iterator.find_map(|command| {
|
||||
let first_token = command.parts.first()?;
|
||||
let alias_value = session.alias_value(&first_token.item)?;
|
||||
is_expandable_alias(&first_token.item, alias_value).then(|| AliasedCommand {
|
||||
alias: first_token.item.as_str().into(),
|
||||
alias_value: alias_value.into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_for_alias_async(source: &str, session: Arc<Session>) -> Option<AliasedCommand> {
|
||||
check_for_alias(source, session)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "alias_tests.rs"]
|
||||
pub mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
use smol_str::SmolStr;
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::model::session::{
|
||||
command_executor::testing::TestCommandExecutor, SessionInfo,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_is_expandable_alias_when_expandable() {
|
||||
// Alias is not in the alias value
|
||||
let expandable = is_expandable_alias("gco", "git checkout");
|
||||
assert!(expandable);
|
||||
|
||||
// Alias is in the alias value but not in a command position
|
||||
let expandable = is_expandable_alias("gco", "git checkout gco");
|
||||
assert!(expandable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_expandable_alias_when_unexpandable() {
|
||||
let expandable = is_expandable_alias("ls", "ls -G");
|
||||
assert!(!expandable);
|
||||
|
||||
let expandable = is_expandable_alias("ls", "ls");
|
||||
assert!(!expandable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_expandable_alias_when_alias_value_is_empty() {
|
||||
let expandable = is_expandable_alias("ls", "");
|
||||
assert!(!expandable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_for_alias_has_alias() {
|
||||
let alias: SmolStr = "gco".into();
|
||||
let alias_value: String = "git checkout".into();
|
||||
let aliases = HashMap::from_iter([(alias.clone(), alias_value.clone())]);
|
||||
let session = Arc::new(Session::new(
|
||||
SessionInfo::new_for_test().with_aliases(aliases),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
));
|
||||
let expected = Some(AliasedCommand { alias, alias_value });
|
||||
|
||||
// The alias is a command.
|
||||
let res = check_for_alias("gco branch", session.clone());
|
||||
assert_eq!(res, expected);
|
||||
|
||||
// The alias is returned when it is not the first command
|
||||
let res = check_for_alias("echo hello && gco branch", session);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_for_alias_no_alias() {
|
||||
let aliases = HashMap::from_iter([
|
||||
("gco".into(), "git checkout".into()),
|
||||
("ls".into(), "ls -G".into()),
|
||||
]);
|
||||
let session = Arc::new(Session::new(
|
||||
SessionInfo::new_for_test().with_aliases(aliases),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
));
|
||||
|
||||
// No alias is returned if no aliases are in the command.
|
||||
let res = check_for_alias("echo hello", session.clone());
|
||||
assert_eq!(res, None);
|
||||
|
||||
// No alias is returned if the alias is in a non-command position.
|
||||
let res = check_for_alias("echo gco", session.clone());
|
||||
assert_eq!(res, None);
|
||||
|
||||
// No alias is returned if the alias is not expandable.
|
||||
let res = check_for_alias("ls", session);
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
use settings::Setting as _;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
alt_screen_reporting::AltScreenReporting, model::grid::grid_handler::TermMode, TerminalModel,
|
||||
};
|
||||
|
||||
pub mod alt_screen_element;
|
||||
|
||||
/// Determines if mouse event is intercepted based on SGR_MOUSE mode and mouse reporting setting.
|
||||
pub fn should_intercept_mouse(model: &TerminalModel, shift: bool, ctx: &AppContext) -> bool {
|
||||
// Always intercept mouse for a shared session reader since their mouse events
|
||||
// will not be processed by the sharer's running terminal app.
|
||||
if model.shared_session_status().is_reader() || shift {
|
||||
return true;
|
||||
}
|
||||
// Require some level of mouse tracking to be enabled when the block list is active.
|
||||
let mouse_tracking = model.is_alt_screen_active()
|
||||
|| model.is_term_mode_set(TermMode::MOUSE_REPORT_CLICK)
|
||||
|| model.is_term_mode_set(TermMode::MOUSE_DRAG)
|
||||
|| model.is_term_mode_set(TermMode::MOUSE_MOTION);
|
||||
let mouse_reporting_enabled = *AltScreenReporting::as_ref(ctx)
|
||||
.mouse_reporting_enabled
|
||||
.value();
|
||||
!(model.is_term_mode_set(TermMode::SGR_MOUSE) && mouse_tracking && mouse_reporting_enabled)
|
||||
}
|
||||
|
||||
/// Determines if scroll event is intercepted. SGR_mouse and mouse reporting must be enabled to
|
||||
/// report scroll events, otherwise, always intercept scroll.
|
||||
pub fn should_intercept_scroll(model: &TerminalModel, ctx: &AppContext) -> bool {
|
||||
let scroll_reporting_enabled = *AltScreenReporting::as_ref(ctx)
|
||||
.scroll_reporting_enabled
|
||||
.value();
|
||||
should_intercept_mouse(model, false, ctx) || !scroll_reporting_enabled
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(AltScreenReporting, settings: [
|
||||
mouse_reporting_enabled: MouseReportingEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.mouse_reporting_enabled",
|
||||
description: "Whether to forward mouse events to full-screen terminal applications.",
|
||||
},
|
||||
scroll_reporting_enabled: ScrollReportingEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.scroll_reporting_enabled",
|
||||
description: "Whether to forward scroll events to full-screen terminal applications.",
|
||||
},
|
||||
focus_reporting_enabled: FocusReportingEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.focus_reporting_enabled",
|
||||
description: "Whether to forward focus and blur events to full-screen terminal applications.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Module containing an implementation of an audible bell for x11 backends.
|
||||
|
||||
use anyhow::bail;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// An X11 backed implementation of an audible bell.
|
||||
pub(super) struct AudibleBell {
|
||||
connection: Option<RustConnection>,
|
||||
}
|
||||
|
||||
impl AudibleBell {
|
||||
pub fn new() -> Self {
|
||||
let connection = RustConnection::connect(None)
|
||||
.ok()
|
||||
.map(|(connection, _)| connection);
|
||||
Self { connection }
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> anyhow::Result<()> {
|
||||
let Some(connection) = &self.connection else {
|
||||
bail!("Unable to establish connection to x11 server")
|
||||
};
|
||||
// Play the bell at 0%. By using 0%, we indicate to the x server that the bell should be played at the user's
|
||||
// current volume. See https://www.x.org/releases/X11R7.7/doc/xproto/x11protocol.html#requests:Bell for more
|
||||
// details.
|
||||
let cookie = connection.bell(0)?;
|
||||
cookie.check()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Module containing a macOS implementation of an audible bell.
|
||||
|
||||
extern "C" {
|
||||
pub fn NSBeep();
|
||||
}
|
||||
|
||||
/// A MacOS implementation of an audible bell.
|
||||
pub(super) struct AudibleBell;
|
||||
|
||||
impl AudibleBell {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
NSBeep();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Module containing the definition of a platform agnostic audible bell.
|
||||
|
||||
use anyhow::Result;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
#[cfg_attr(target_os = "linux", 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.
|
||||
#[cfg_attr(target_family = "wasm", path = "noop.rs")]
|
||||
mod imp;
|
||||
|
||||
/// A singleton model that provides a way convenient way to make a "beep" when rung (via a call to
|
||||
/// [`AudibleBell::ring`]).
|
||||
pub struct AudibleBell(imp::AudibleBell);
|
||||
|
||||
impl AudibleBell {
|
||||
pub fn new() -> Self {
|
||||
Self(imp::AudibleBell::new())
|
||||
}
|
||||
|
||||
/// Rings the audible bell. Returns an [`Err`] if the bell was unable to be rung for any reason.
|
||||
pub fn ring(&self) -> Result<()> {
|
||||
self.0.ring()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudibleBell {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AudibleBell {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AudibleBell {}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Module containing a noop implementation of an audible bell.
|
||||
pub(super) struct AudibleBell;
|
||||
|
||||
impl AudibleBell {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Module containing a Windows implementation of an audible bell.
|
||||
|
||||
use windows::Win32::System::Diagnostics::Debug::MessageBeep;
|
||||
use windows::Win32::UI::WindowsAndMessaging::MB_OK;
|
||||
pub(super) struct AudibleBell;
|
||||
|
||||
impl AudibleBell {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn ring(&self) -> anyhow::Result<()> {
|
||||
Ok(unsafe { MessageBeep(MB_OK) }?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
use std::borrow::Cow;
|
||||
#[cfg(feature = "local_tty")]
|
||||
use std::collections::{HashMap, HashSet};
|
||||
#[cfg(feature = "local_tty")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
use settings::Setting as _;
|
||||
#[cfg(feature = "local_tty")]
|
||||
use warpui::{AppContext, ModelContext};
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
#[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,
|
||||
executable_path: PathBuf,
|
||||
shell_type: ShellType,
|
||||
}
|
||||
|
||||
impl TryFrom<StartupShell> for LocalConfig {
|
||||
type Error = ();
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
fn try_from(value: StartupShell) -> Result<Self, Self::Error> {
|
||||
use crate::terminal::local_tty::shell::supported_shell_path_and_type;
|
||||
|
||||
let command = value.shell_command().ok_or(())?;
|
||||
let (path, shell_type) = supported_shell_path_and_type(command).ok_or(())?;
|
||||
Ok(Self {
|
||||
command: command.to_string(),
|
||||
executable_path: path.to_path_buf(),
|
||||
shell_type,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_tty"))]
|
||||
fn try_from(_value: StartupShell) -> Result<Self, Self::Error> {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The state for the AvailableShell model. Is kept private to the module, b/c we do not want people
|
||||
/// manually destructuring or matching on this data.
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
enum Config {
|
||||
SystemDefault,
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
KnownLocal(LocalConfig),
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
Wsl {
|
||||
distro: String,
|
||||
},
|
||||
#[cfg_attr(any(not(feature = "local_tty"), unix), allow(dead_code))]
|
||||
MSYS2(LocalConfig),
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
Custom(LocalConfig),
|
||||
/// A shell running inside a Docker sandbox via `sbx run`.
|
||||
///
|
||||
/// Unlike local shells, we don't pick the shell binary or path here: the
|
||||
/// shell that actually runs comes from the container image. This mirrors
|
||||
/// how [`Config::Wsl`] carries just the distro name and defers the shell
|
||||
/// choice to WSL itself.
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
DockerSandbox {
|
||||
/// Path to the `sbx` CLI binary on the host.
|
||||
sbx_path: PathBuf,
|
||||
/// Base Docker image to use when creating the sandbox (passed as
|
||||
/// `sbx run --template <image>`). `None` means "use sbx's default
|
||||
/// image".
|
||||
base_image: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// The concept of specifying an available shell does not exist on non-local filesystems. So we allow
|
||||
// dead code so that the concept of the struct can exist, but remove any methods that do anything
|
||||
// with it. That way, method calls can still take `Option<AvailableShell>` as an argument, but
|
||||
// remote_ttys can just specify `None` for the value.
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code,))]
|
||||
/// Contains the config describing a 'shell' that can be launched for a new session. Currently falls
|
||||
/// into 4 categories:
|
||||
/// - 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.
|
||||
/// - 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
|
||||
/// are using a custom shell, you should not be constructing this struct directly. Instead, use the
|
||||
/// methods available on the `AvailableShells` model.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct AvailableShell {
|
||||
id: Option<String>,
|
||||
state: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AvailableShell {
|
||||
/// Returns an "id" associated with a known shell. If
|
||||
/// shell1.is_some_and(|id1| shell2.is_some_and(|id2| id1 == id2)) holds,
|
||||
/// then shell1 and shell2 are the same shell.
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.id.as_deref()
|
||||
}
|
||||
|
||||
pub fn short_name(&self) -> Cow<'_, str> {
|
||||
match self.state.as_ref() {
|
||||
Config::SystemDefault => Cow::from("Default"),
|
||||
Config::KnownLocal(LocalConfig { command, .. })
|
||||
| Config::MSYS2(LocalConfig { command, .. }) => match command.as_str() {
|
||||
"bash" => Cow::from("Bash"),
|
||||
"zsh" => Cow::from("Zsh"),
|
||||
"fish" => Cow::from("Fish"),
|
||||
"pwsh" | "pwsh.exe" => Cow::from("PowerShell"),
|
||||
"powershell" | "powershell.exe" => Cow::from("Windows PowerShell"),
|
||||
_ => Cow::from(command),
|
||||
},
|
||||
Config::Wsl { distro } => Cow::from(distro),
|
||||
Config::Custom(_) => Cow::from("Custom"),
|
||||
Config::DockerSandbox { .. } => Cow::from("Docker Sandbox"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn details(&self) -> Cow<'_, str> {
|
||||
match self.state.as_ref() {
|
||||
Config::SystemDefault => Cow::from("System default shell"),
|
||||
Config::KnownLocal(LocalConfig {
|
||||
executable_path, ..
|
||||
})
|
||||
| Config::MSYS2(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => Cow::from(format!("{}", executable_path.display())),
|
||||
Config::Wsl { .. } => Cow::from("Windows Subsystem for Linux"),
|
||||
Config::Custom(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => Cow::from(format!("Custom: {}", executable_path.display())),
|
||||
Config::DockerSandbox { .. } => Cow::from("Docker Sandbox"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a telemetry string that represents the value of the shell without any PII involved.
|
||||
pub fn telemetry_value(&self) -> String {
|
||||
// NOTE: These explicitly do not log the shell path or wsl name, as those can contain PII.
|
||||
match self.state.as_ref() {
|
||||
Config::SystemDefault => "SystemDefault".to_string(),
|
||||
Config::KnownLocal(config) | Config::MSYS2(config) => config.command.clone(),
|
||||
Config::Wsl { .. } => "WSL".to_string(),
|
||||
Config::Custom(_) => "Custom".to_string(),
|
||||
Config::DockerSandbox { .. } => "DockerSandbox".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wsl_distro(&self) -> Option<String> {
|
||||
match self.state.as_ref() {
|
||||
Config::Wsl { distro } => Some(distro.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_wsl(&self) -> bool {
|
||||
matches!(self.state.as_ref(), Config::Wsl { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
impl AvailableShell {
|
||||
/// The long name of the shell. For local shells, this includes the path to
|
||||
/// the executable.
|
||||
fn long_name(&self) -> String {
|
||||
match &self.state.as_ref() {
|
||||
Config::SystemDefault => "Default".to_string(),
|
||||
Config::KnownLocal(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => format!("{} ({})", self.short_name(), executable_path.display()),
|
||||
Config::Wsl { distro } => distro.to_string(),
|
||||
Config::Custom(LocalConfig { command, .. }) => format!("Custom ({command})"),
|
||||
Config::MSYS2(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => format!("{} ({})", self.short_name(), executable_path.display()),
|
||||
Config::DockerSandbox { .. } => "Docker Sandbox".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// If the shell is a Custom shell, returns the custom shell path. Otherwise returns None. Can
|
||||
/// also be used to assert whether or not the shell is a custom shell using `is_some()`.
|
||||
pub fn get_custom_path(&self) -> Option<String> {
|
||||
if let Config::Custom(config) = self.state.as_ref() {
|
||||
Some(config.executable_path.display().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_preference(&self, preference: &NewSessionShell) -> bool {
|
||||
match preference {
|
||||
NewSessionShell::SystemDefault => matches!(self.state.as_ref(), Config::SystemDefault),
|
||||
NewSessionShell::Executable(path) => {
|
||||
matches!(self.state.as_ref(), Config::KnownLocal(LocalConfig { executable_path, .. }) if executable_path == Path::new(path))
|
||||
}
|
||||
NewSessionShell::WSL(distro) => {
|
||||
matches!(self.state.as_ref(), Config::Wsl { distro: d } if d == distro)
|
||||
}
|
||||
NewSessionShell::Custom(path) => {
|
||||
matches!(self.state.as_ref(), Config::Custom(LocalConfig { executable_path, .. }) if executable_path == Path::new(path))
|
||||
}
|
||||
NewSessionShell::MSYS2(path) => {
|
||||
matches!(self.state.as_ref(), Config::MSYS2(LocalConfig{ executable_path, .. } ) if executable_path == Path::new(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the launch data for a shell.
|
||||
///
|
||||
/// Our conversion methodology is as follows:
|
||||
///
|
||||
/// | [`AvailableShell`] | Validation | [`Option<ShellLaunchData>`] |
|
||||
/// |:---------------------------|:----------:|-----------------------------------:|
|
||||
/// | [`Config::SystemDefault`] | No | [`Option::None`] |
|
||||
/// | [`Config::KnownLocal`] | Yes | [`ShellLaunchData::Executable`] |
|
||||
/// | [`Config::Wsl`] | No | [`ShellLaunchData::WSL`] |
|
||||
/// | [`Config::Custom`] | Yes | [`ShellLaunchData::Executable`] |
|
||||
/// | [`Config::MSYS2`] | No | [`ShellLaunchData::MSYS2`] |
|
||||
/// | [`Config::DockerSandbox`] | No | [`ShellLaunchData::DockerSandbox`] |
|
||||
///
|
||||
/// For `KnownLocal` and `Custom` we validate that the executable path
|
||||
/// is still valid.
|
||||
pub fn get_valid_shell_path_and_type(&self) -> Option<ShellLaunchData> {
|
||||
match self.state.as_ref() {
|
||||
Config::SystemDefault => None,
|
||||
Config::KnownLocal(LocalConfig {
|
||||
executable_path,
|
||||
shell_type,
|
||||
..
|
||||
})
|
||||
| Config::Custom(LocalConfig {
|
||||
executable_path,
|
||||
shell_type,
|
||||
..
|
||||
}) => {
|
||||
// We already did the supported_shell_path_and_type when constructing the model, but
|
||||
// in case the model is out of date we want to verify that the exe is still there.
|
||||
if file_exists_and_is_executable(executable_path) {
|
||||
Some(ShellLaunchData::Executable {
|
||||
executable_path: executable_path.clone(),
|
||||
shell_type: *shell_type,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Config::Wsl { distro } => Some(ShellLaunchData::WSL {
|
||||
distro: distro.to_string(),
|
||||
}),
|
||||
Config::MSYS2(local_config) => Some(ShellLaunchData::MSYS2 {
|
||||
executable_path: local_config.executable_path.clone(),
|
||||
shell_type: local_config.shell_type,
|
||||
}),
|
||||
Config::DockerSandbox {
|
||||
sbx_path,
|
||||
base_image,
|
||||
} => Some(ShellLaunchData::DockerSandbox {
|
||||
sbx_path: sbx_path.clone(),
|
||||
base_image: base_image.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_local_executable(
|
||||
command: String,
|
||||
executable_path: PathBuf,
|
||||
shell_type: ShellType,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Some(format!("local:{}", executable_path.display())),
|
||||
state: Arc::new(Config::KnownLocal(LocalConfig {
|
||||
command,
|
||||
executable_path,
|
||||
shell_type,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn new_msys2(command: String, executable_path: PathBuf, shell_type: ShellType) -> Self {
|
||||
Self {
|
||||
id: Some(format!("msys2:{}", executable_path.display())),
|
||||
state: Arc::new(Config::MSYS2(LocalConfig {
|
||||
command,
|
||||
executable_path,
|
||||
shell_type,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn new_wsl(distro: String) -> Self {
|
||||
Self {
|
||||
id: Some(format!("wsl:{distro}")),
|
||||
state: Arc::new(Config::Wsl { distro }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_custom_shell(
|
||||
command: String,
|
||||
executable_path: PathBuf,
|
||||
shell_type: ShellType,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
state: Arc::new(Config::Custom(LocalConfig {
|
||||
command,
|
||||
executable_path,
|
||||
shell_type,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_docker_sandbox_shell(sbx_path: PathBuf, base_image: Option<String>) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
state: Arc::new(Config::DockerSandbox {
|
||||
sbx_path,
|
||||
base_image,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_docker_sandbox(&self) -> bool {
|
||||
matches!(self.state.as_ref(), Config::DockerSandbox { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AvailableShell> for NewSessionShell {
|
||||
fn from(value: AvailableShell) -> Self {
|
||||
match value.state.as_ref() {
|
||||
Config::SystemDefault => NewSessionShell::SystemDefault,
|
||||
Config::KnownLocal(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => NewSessionShell::Executable(executable_path.display().to_string()),
|
||||
Config::Wsl { distro } => NewSessionShell::WSL(distro.clone()),
|
||||
Config::Custom(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => NewSessionShell::Custom(executable_path.display().to_string()),
|
||||
Config::MSYS2(local_config) => {
|
||||
NewSessionShell::MSYS2(local_config.executable_path.display().to_string())
|
||||
}
|
||||
// Docker sandbox isn't a persistable "preferred shell" today —
|
||||
// it's always launched on-demand via a tab action or slash
|
||||
// command. Round-trip through settings falls back to the system
|
||||
// default.
|
||||
// TODO(advait): If we ever let users pin the sandbox as their
|
||||
// default shell, add a `NewSessionShell::DockerSandbox` variant.
|
||||
Config::DockerSandbox { .. } => NewSessionShell::SystemDefault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AvailableShell> for StartupShell {
|
||||
fn from(value: AvailableShell) -> Self {
|
||||
match value.state.as_ref() {
|
||||
Config::SystemDefault | Config::Wsl { .. } => StartupShell::Default,
|
||||
Config::KnownLocal(LocalConfig { shell_type, .. }) => {
|
||||
StartupShell::from(Some(shell_type.name().to_string()))
|
||||
}
|
||||
Config::Custom(LocalConfig {
|
||||
executable_path, ..
|
||||
}) => StartupShell::Custom(executable_path.display().to_string()),
|
||||
Config::MSYS2(local_config) => {
|
||||
StartupShell::from(Some(local_config.shell_type.name().to_string()))
|
||||
}
|
||||
// See the matching comment on `From<AvailableShell> for
|
||||
// NewSessionShell`: the sandbox isn't persistable as a startup
|
||||
// shell today, so fall back to default.
|
||||
Config::DockerSandbox { .. } => StartupShell::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AvailableShell {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
state: Arc::new(Config::SystemDefault),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
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()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.ok_or(())?
|
||||
.to_string();
|
||||
Ok(Self::new_custom_shell(command, path, shell_type))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
impl TryFrom<NewSessionShell> for AvailableShell {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: NewSessionShell) -> Result<Self, Self::Error> {
|
||||
if let NewSessionShell::Custom(path) = &value {
|
||||
Ok(AvailableShell::try_from(path.as_str())?)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub struct AvailableShells {
|
||||
shells: Vec<AvailableShell>,
|
||||
/// A map of shell name to the number of times it appears in the list.
|
||||
#[cfg(feature = "local_tty")]
|
||||
shell_counts: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_tty"))]
|
||||
impl AvailableShells {
|
||||
pub fn get_available_shells(&self) -> impl Iterator<Item = &AvailableShell> {
|
||||
std::iter::empty()
|
||||
}
|
||||
|
||||
pub fn get_from_shell_launch_data(&self, _config: &ShellLaunchData) -> Option<AvailableShell> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
impl AvailableShells {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
let fallback_shells_path = cfg!(unix).then_some(Path::new("/etc/shells"));
|
||||
|
||||
let env_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
#[cfg_attr(not(windows), allow(unused_mut))]
|
||||
let mut paths_to_search = std::env::split_paths(&env_path).collect::<Vec<PathBuf>>();
|
||||
|
||||
// The PATH here is limited since it doesn't include the locations added
|
||||
// by the user's login shell. We add the Homebrew installer locations to
|
||||
// the search paths so we can detect shells installed via Homebrew.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Apple Silicon homebrew path
|
||||
paths_to_search.push(PathBuf::from("/opt/homebrew/bin"));
|
||||
// Intel homebrew path
|
||||
paths_to_search.push(PathBuf::from("/usr/local/bin"));
|
||||
}
|
||||
|
||||
// The user may have installed PowerShell in locations not in the system
|
||||
// path. We add some typical install locations for PowerShell to the paths
|
||||
// to search.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use crate::util::windows;
|
||||
|
||||
paths_to_search.extend(windows::powershell_7_install_paths());
|
||||
paths_to_search.push(windows::powershell_5_install_path());
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), allow(unused_mut))]
|
||||
let mut shells = Self::load_known_shells(&paths_to_search, fallback_shells_path);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ctx = _ctx;
|
||||
shells.extend(
|
||||
super::wsl::WslInfo::as_ref(ctx)
|
||||
.distributions()
|
||||
.map(|distro| AvailableShell::new_wsl(distro.name.to_owned())),
|
||||
);
|
||||
}
|
||||
|
||||
let shell_counts = shells.iter().fold(HashMap::new(), |mut counts, shell| {
|
||||
let count = counts.entry(shell.short_name().to_string()).or_insert(0);
|
||||
*count += 1;
|
||||
counts
|
||||
});
|
||||
Self {
|
||||
shells,
|
||||
shell_counts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterable that iterates over all available shells.
|
||||
pub fn get_available_shells(&self) -> impl Iterator<Item = &AvailableShell> {
|
||||
self.shells.iter()
|
||||
}
|
||||
|
||||
/// Returns the display name for a shell, context aware of the other
|
||||
/// available shells. If the shell appears only once, we return the short
|
||||
/// name. If the shell appears multiple times, we disambiguate by showing
|
||||
/// the full name with the path to the executable.
|
||||
pub fn display_name_for_shell<'a>(&self, shell: &'a AvailableShell) -> Cow<'a, str> {
|
||||
if self
|
||||
.shell_counts
|
||||
.get::<str>(shell.short_name().as_ref())
|
||||
.is_some_and(|count| *count > 1)
|
||||
{
|
||||
Cow::from(shell.long_name())
|
||||
} else {
|
||||
shell.short_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to convert from ShellLaunchData into an AvailableShell
|
||||
///
|
||||
/// **Note: This is here b/c we need to be able to get an AvailableShell
|
||||
/// from snapshot data. You probably should not be calling this.**
|
||||
///
|
||||
/// The methodology is:
|
||||
///
|
||||
/// 1. Search for a matching shell in the list of AvailableShells.
|
||||
/// If it exists, then we return it. This covers all `KnownLocal` or `WSL` cases.
|
||||
/// 2. If we don't find it in the list, and the ShellLaunchData is `WSL`, that means
|
||||
/// the wsl distro is no longer valid, so we return None.
|
||||
/// 3. If we have `Executable`` data that has not matched the list, we convert it to
|
||||
/// a custom shell. This covers the case of either a Custom shell or a KnownLocal
|
||||
/// that for whatever reason is no longer valid. An invalid Custom or KnownLocal
|
||||
/// will still be validated before launching a shell (and fall back to default),
|
||||
/// so it is fine to return a Custom here.
|
||||
///
|
||||
/// See [`AvailableShell::get_valid_shell_path_and_type`] for more information on how
|
||||
/// we generate these launch configs in the other direction.
|
||||
pub fn get_from_shell_launch_data(&self, config: &ShellLaunchData) -> Option<AvailableShell> {
|
||||
self.shells
|
||||
.iter()
|
||||
.find(|shell| match (shell.state.as_ref(), config) {
|
||||
(Config::Wsl { distro }, ShellLaunchData::WSL { distro: d }) => distro == d,
|
||||
(
|
||||
Config::MSYS2(LocalConfig {
|
||||
executable_path, ..
|
||||
}),
|
||||
ShellLaunchData::MSYS2 {
|
||||
executable_path: p, ..
|
||||
},
|
||||
) => executable_path == p,
|
||||
(
|
||||
Config::KnownLocal(LocalConfig {
|
||||
executable_path,
|
||||
shell_type,
|
||||
..
|
||||
}),
|
||||
ShellLaunchData::Executable {
|
||||
executable_path: p,
|
||||
shell_type: t,
|
||||
},
|
||||
) => executable_path == p && shell_type == t,
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if let ShellLaunchData::Executable {
|
||||
executable_path,
|
||||
shell_type,
|
||||
} = config
|
||||
{
|
||||
Some(AvailableShell::new_custom_shell(
|
||||
executable_path.file_name()?.to_str()?.to_string(),
|
||||
executable_path.clone(),
|
||||
*shell_type,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks the user preferences for the shell to use for new sessions, returning an available
|
||||
/// shell object.
|
||||
pub fn get_user_preferred_shell(&self, ctx: &AppContext) -> AvailableShell {
|
||||
let preference = self.get_user_preferred_shell_setting(ctx);
|
||||
|
||||
match preference {
|
||||
NewSessionShell::SystemDefault => AvailableShell::default(),
|
||||
NewSessionShell::Custom(_) => AvailableShell::try_from(preference).unwrap_or_default(),
|
||||
// TODO(DAN): This should be cached in the model. Also handle custom
|
||||
preference => self
|
||||
.shells
|
||||
.iter()
|
||||
.find(|shell| shell.matches_preference(&preference))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the user-preferred shell for new sessions. Saves the value back to user settings.
|
||||
pub fn set_user_preferred_shell(
|
||||
&self,
|
||||
value: AvailableShell,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
use super::session_settings::SessionSettings;
|
||||
use warp_core::features::FeatureFlag;
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if FeatureFlag::ShellSelector.is_enabled() {
|
||||
settings
|
||||
.new_session_shell_override
|
||||
.set_value(Some(NewSessionShell::from(value)), ctx)
|
||||
} else {
|
||||
settings
|
||||
.startup_shell_override
|
||||
.set_value(StartupShell::from(value), ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn load_known_shells(
|
||||
paths_to_search: &[PathBuf],
|
||||
fallback_path: Option<&Path>,
|
||||
) -> Vec<AvailableShell> {
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
if !FeatureFlag::ShellSelector.is_enabled() {
|
||||
return vec![
|
||||
StartupShell::Zsh,
|
||||
StartupShell::Bash,
|
||||
StartupShell::Fish,
|
||||
StartupShell::PowerShell,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|shell| {
|
||||
let state = LocalConfig::try_from(shell).ok()?;
|
||||
Some(AvailableShell {
|
||||
id: None,
|
||||
state: Arc::new(Config::KnownLocal(state)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let shell_types = Self::get_shell_types();
|
||||
|
||||
let mut known_shells = vec![];
|
||||
let mut fallback_shell_map = fallback_path
|
||||
.and_then(|path| Self::load_fallback_shells(path, &shell_types).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (shell_type, command_name) in shell_types {
|
||||
#[cfg(windows)]
|
||||
if ["bash.exe", "fish.exe", "zsh.exe"].contains(&command_name) {
|
||||
let msys2_executables = Self::locate_msys2_executables();
|
||||
for msys2_path in &msys2_executables {
|
||||
if msys2_path.ends_with(command_name) {
|
||||
known_shells.push(AvailableShell::new_msys2(
|
||||
command_name.to_string(),
|
||||
msys2_path.clone(),
|
||||
shell_type,
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut fallback_shells = fallback_shell_map.remove(command_name).unwrap_or_default();
|
||||
for path in Self::resolve_all_executables(command_name, paths_to_search.iter()) {
|
||||
fallback_shells.remove(&path);
|
||||
known_shells.push(AvailableShell::new_local_executable(
|
||||
command_name.to_string(),
|
||||
path,
|
||||
shell_type,
|
||||
));
|
||||
}
|
||||
|
||||
// We append shells found in /etc/shells but not the path after the shells found on the path.
|
||||
for path in fallback_shells.iter() {
|
||||
if file_exists_and_is_executable(path) {
|
||||
known_shells.push(AvailableShell::new_local_executable(
|
||||
command_name.to_string(),
|
||||
path.clone(),
|
||||
shell_type,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
known_shells
|
||||
}
|
||||
|
||||
fn get_shell_types() -> Vec<(ShellType, &'static str)> {
|
||||
if cfg!(windows) {
|
||||
vec![
|
||||
(ShellType::PowerShell, "pwsh.exe"),
|
||||
(ShellType::PowerShell, "powershell.exe"),
|
||||
(ShellType::Zsh, "zsh.exe"),
|
||||
(ShellType::Bash, "bash.exe"),
|
||||
(ShellType::Fish, "fish.exe"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
(ShellType::Zsh, "zsh"),
|
||||
(ShellType::Bash, "bash"),
|
||||
(ShellType::Fish, "fish"),
|
||||
(ShellType::PowerShell, "pwsh"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks for UNIX shells running "natively" on Windows via MSYS2.
|
||||
///
|
||||
/// Attempts to find Git Bash executables at a few locations:
|
||||
/// 1. `$env:LocalAppData\Programs\Git\usr\bin`
|
||||
/// 2. `$env:ProgramFiles\Git\usr\bin`
|
||||
/// 3. Where scoop may have installed it
|
||||
/// We don't want to search the PATH for a `bash.exe` since there are false positives.
|
||||
/// Also search for bash/fish/zsh in the
|
||||
#[cfg(windows)]
|
||||
fn locate_msys2_executables() -> Vec<PathBuf> {
|
||||
use std::env;
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// We look for Git Bash at `$env:LocalAppData\Programs\Git\usr\bin`.
|
||||
match env::var("LocalAppData") {
|
||||
Ok(local_app_data_path) => {
|
||||
let user_location = Path::new(&local_app_data_path)
|
||||
.join("Programs")
|
||||
.join("Git")
|
||||
.join("usr")
|
||||
.join("bin")
|
||||
.join("bash.exe");
|
||||
if file_exists_and_is_executable(&user_location) {
|
||||
paths.push(user_location);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Environment variable LocalAppData not found {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
// Next, we look for Git Bash at `$env:ProgramFiles\Git\usr\bin`.
|
||||
match env::var("ProgramFiles") {
|
||||
Ok(program_files_path) => {
|
||||
let global_location = Path::new(&program_files_path)
|
||||
.join("Git")
|
||||
.join("usr")
|
||||
.join("bin")
|
||||
.join("bash.exe");
|
||||
if file_exists_and_is_executable(&global_location) {
|
||||
paths.push(global_location);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Environment variable ProgramFiles not found {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
// Next, we look for Git Bash where scoop, a package manager, may have installed it at
|
||||
// `$env:USERPROFILE\scoop\apps\git\current\usr\bin`.
|
||||
// Note that `current` is a symbolic link to the folder with the latest version.
|
||||
if let Ok(user_profile) = env::var("USERPROFILE") {
|
||||
let maybe_scoop_path = Path::new(&user_profile)
|
||||
.join("scoop")
|
||||
.join("apps")
|
||||
.join("git")
|
||||
.join("current")
|
||||
.join("usr")
|
||||
.join("bin")
|
||||
.join("bash.exe");
|
||||
if file_exists_and_is_executable(&maybe_scoop_path) {
|
||||
paths.push(maybe_scoop_path);
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::MSYS2Shells.is_enabled() {
|
||||
// Search the default install location for MSYS2 shells (installed via pacman).
|
||||
match env::var("SystemDrive") {
|
||||
Ok(system_drive) => {
|
||||
let msys_bin = Path::new(&format!(r"{system_drive}\"))
|
||||
.join("msys64")
|
||||
.join("usr")
|
||||
.join("bin");
|
||||
for shell in ["bash.exe", "fish.exe", "zsh.exe"] {
|
||||
let msys_shell = msys_bin.join(shell);
|
||||
if file_exists_and_is_executable(&msys_shell) {
|
||||
paths.push(msys_shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Environment variable SystemDrive not found {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Resolves all full paths to executables of the given command name in PATH.
|
||||
///
|
||||
/// `paths_to_search` should contain the locations in PATH along with any
|
||||
/// manually added paths that we want to search.
|
||||
fn resolve_all_executables<'a>(
|
||||
command: &str,
|
||||
paths_to_search: impl Iterator<Item = &'a PathBuf>,
|
||||
) -> Vec<PathBuf> {
|
||||
use itertools::Itertools as _;
|
||||
|
||||
paths_to_search
|
||||
.filter_map(|single_path| {
|
||||
let joined = single_path.join(command);
|
||||
let canonicalized = dunce::canonicalize(&joined).unwrap_or(joined);
|
||||
file_exists_and_is_executable(&canonicalized).then_some(canonicalized)
|
||||
})
|
||||
.unique()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_fallback_shells(
|
||||
path: &Path,
|
||||
shell_types: &[(ShellType, &str)],
|
||||
) -> anyhow::Result<HashMap<String, HashSet<PathBuf>>> {
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let mut shells = HashMap::new();
|
||||
|
||||
let file = File::open(path)?;
|
||||
|
||||
for (_, exe) in shell_types.iter() {
|
||||
shells.insert(exe.to_string(), HashSet::new());
|
||||
}
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
// For each line in /etc/shells, we check:
|
||||
// - does it not start with a #?
|
||||
// - is it not empty?
|
||||
// - does the "file_name" map to a shell that we support?
|
||||
//
|
||||
// If all of those are true, then we add it to the set of paths associated with that shell
|
||||
if !line.trim_start().starts_with('#') && !line.trim().is_empty() {
|
||||
let Ok(path) = dunce::canonicalize(line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
|
||||
if let Some(set) = shells.get_mut(file_name) {
|
||||
set.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(shells)
|
||||
}
|
||||
|
||||
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
|
||||
.to_owned();
|
||||
|
||||
new_session_shell_override.unwrap_or_else(|| {
|
||||
// Fallback logic in case the new_session_shell has not been set.
|
||||
// We attempt to read the legacy setting startup_shell_override, and map it to a
|
||||
// NewSessionShell. If that mapping fails at all, we set it to NewSessionShell::SystemDefault.
|
||||
// We also write back to the new_session_shell_override setting so that we don't have to do this
|
||||
// all the time.
|
||||
self.get_user_preferred_shell_setting_fallback(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
.to_owned();
|
||||
match startup_shell {
|
||||
StartupShell::Default => NewSessionShell::SystemDefault,
|
||||
StartupShell::Custom(path) => NewSessionShell::Custom(path),
|
||||
_ => startup_shell
|
||||
.shell_command()
|
||||
.and_then(|command| {
|
||||
for shell in self.shells.iter() {
|
||||
if let Config::KnownLocal(LocalConfig {
|
||||
command: shell_command,
|
||||
executable_path,
|
||||
..
|
||||
}) = shell.state.as_ref()
|
||||
{
|
||||
if shell_command == command {
|
||||
return Some(NewSessionShell::Executable(
|
||||
executable_path.display().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or(NewSessionShell::SystemDefault),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the first shell that matches the given shell type.
|
||||
pub fn find_known_shell_by_type(&self, shell: ShellType) -> Option<AvailableShell> {
|
||||
self.shells
|
||||
.iter()
|
||||
.find(|s| matches!(s.state.as_ref(), Config::KnownLocal(LocalConfig { shell_type, .. }) if shell == *shell_type))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Finds the first known shell whose command name matches `name`.
|
||||
///
|
||||
/// This is used to resolve a bare shell name from a tab config (e.g.
|
||||
/// `shell = "pwsh"`) against shells that [`AvailableShells::new`] has
|
||||
/// already discovered. Because that discovery supplements the process
|
||||
/// `PATH` with well-known install locations (such as `/opt/homebrew/bin`
|
||||
/// on macOS), this lookup can find shells that a plain `PATH` search via
|
||||
/// [`AvailableShell::try_from`] would miss when Warp is launched outside
|
||||
/// an interactive shell.
|
||||
///
|
||||
/// Comparison is case-sensitive on Unix. On Windows, where file names are
|
||||
/// case-insensitive and users commonly omit the `.exe` suffix, comparison
|
||||
/// ignores case and an optional trailing `.exe` on either side.
|
||||
pub fn find_by_command_name(&self, name: &str) -> Option<AvailableShell> {
|
||||
self.shells
|
||||
.iter()
|
||||
.find(|shell| {
|
||||
let command = match shell.state.as_ref() {
|
||||
Config::KnownLocal(LocalConfig { command, .. })
|
||||
| Config::MSYS2(LocalConfig { command, .. }) => command.as_str(),
|
||||
Config::Custom(_)
|
||||
| Config::SystemDefault
|
||||
| Config::Wsl { .. }
|
||||
| Config::DockerSandbox { .. } => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
command_name_matches(command, name, cfg!(windows))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether two shell command names are equivalent when resolving a
|
||||
/// tab config's `shell` field. See [`AvailableShells::find_by_command_name`].
|
||||
///
|
||||
/// `is_windows` selects the matching rules:
|
||||
/// - On Windows, file names are case-insensitive and users commonly omit the
|
||||
/// `.exe` suffix (e.g. `shell = "pwsh"`), so matching is case-insensitive
|
||||
/// and ignores an optional trailing `.exe` on either side.
|
||||
/// - On Unix, matching is a plain case-sensitive equality check.
|
||||
///
|
||||
/// Parameterized (rather than keying off `cfg!(windows)` internally) so both
|
||||
/// branches can be unit-tested from any host platform.
|
||||
#[cfg(feature = "local_tty")]
|
||||
fn command_name_matches(stored: &str, requested: &str, is_windows: bool) -> bool {
|
||||
if is_windows {
|
||||
let stored_norm = stored.to_ascii_lowercase();
|
||||
let requested_norm = requested.to_ascii_lowercase();
|
||||
let stored_stem = stored_norm.strip_suffix(".exe").unwrap_or(&stored_norm);
|
||||
let requested_stem = requested_norm
|
||||
.strip_suffix(".exe")
|
||||
.unwrap_or(&requested_norm);
|
||||
stored_stem == requested_stem
|
||||
} else {
|
||||
stored == requested
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AvailableShells {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for AvailableShells {}
|
||||
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub fn register(app: &mut impl warpui::AddSingletonModel) {
|
||||
#[cfg(windows)]
|
||||
app.add_singleton_model(super::wsl::WslInfo::new);
|
||||
app.add_singleton_model(AvailableShells::new);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(windows))]
|
||||
#[path = "available_shells_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,265 @@
|
||||
use super::*;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::test_util::{Stub, VirtualFS};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
fn make_available_shells(shells: Vec<AvailableShell>) -> AvailableShells {
|
||||
AvailableShells {
|
||||
shells,
|
||||
shell_counts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_known_shells_with_empty_path_var() {
|
||||
FeatureFlag::ShellSelector.set_enabled(true);
|
||||
|
||||
// First assert that if there is no fallback, and the env var is empty, we do not load ANY shells
|
||||
let paths_to_search = vec![];
|
||||
let fallback_shells = AvailableShells::load_known_shells(
|
||||
&paths_to_search,
|
||||
Some(Path::new("/some/nonexistent/path")),
|
||||
);
|
||||
assert!(
|
||||
fallback_shells.is_empty(),
|
||||
"expected there to be no shells, but shells contained {fallback_shells:?}"
|
||||
);
|
||||
|
||||
VirtualFS::test(
|
||||
"test_load_known_shells_with_empty_path_var",
|
||||
|dirs, mut sandbox| {
|
||||
let bash = dirs.tests().join("bin").join("bash");
|
||||
let zsh = dirs.tests().join("bin").join("zsh");
|
||||
let fallback_shells_path = dirs.tests().join("etc").join("shells");
|
||||
// Now assert that if there is a fallback, and the env var is empty, we load the fallback
|
||||
sandbox.mkdir("etc");
|
||||
sandbox.mkdir("bin");
|
||||
sandbox.with_files(vec![
|
||||
Stub::FileWithContent(
|
||||
"etc/shells",
|
||||
format!("{}\n{}\n", bash.display(), zsh.display()).as_str(),
|
||||
),
|
||||
Stub::MockExecutable("bin/bash"),
|
||||
Stub::MockExecutable("bin/zsh"),
|
||||
]);
|
||||
|
||||
let fallback_shells = AvailableShells::load_known_shells(
|
||||
&paths_to_search,
|
||||
Some(fallback_shells_path.as_path()),
|
||||
);
|
||||
|
||||
assert_eq!(fallback_shells.len(), 2);
|
||||
// note about this test; current impl is that we add shells in the following order:
|
||||
// zsh, bash, fish, pwsh, powershell
|
||||
// so it is important to assert that even though `/bin/bash` is located before `/bin/zsh`
|
||||
// in the fallback file, we still list `zsh` first.
|
||||
assert_eq!(
|
||||
fallback_shells,
|
||||
vec![
|
||||
AvailableShell {
|
||||
id: Some(format!("local:{}", zsh.display())),
|
||||
state: Arc::new(Config::KnownLocal(LocalConfig {
|
||||
command: "zsh".to_string(),
|
||||
executable_path: zsh,
|
||||
shell_type: ShellType::Zsh,
|
||||
}))
|
||||
},
|
||||
AvailableShell {
|
||||
id: Some(format!("local:{}", bash.display())),
|
||||
state: Arc::new(Config::KnownLocal(LocalConfig {
|
||||
command: "bash".to_string(),
|
||||
executable_path: bash,
|
||||
shell_type: ShellType::Bash,
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedupe_symlinks_when_discovering_paths() {
|
||||
FeatureFlag::ShellSelector.set_enabled(true);
|
||||
VirtualFS::test(
|
||||
"test_dedupe_symlinks_when_discovering_paths",
|
||||
|dirs, mut sandbox| {
|
||||
let bin = dirs.tests().join("bin");
|
||||
let bin_bash = bin.join("bash");
|
||||
let usr_bin = dirs.tests().join("usr").join("bin");
|
||||
let usr_bin_bash = usr_bin.join("bash");
|
||||
let etc_shells = dirs.tests().join("etc").join("shells");
|
||||
|
||||
let paths_to_search = vec![bin, usr_bin];
|
||||
let etc_shells_content =
|
||||
format!("{}\n{}\n", bin_bash.display(), usr_bin_bash.display());
|
||||
|
||||
sandbox.mkdir("etc");
|
||||
sandbox.mkdir("usr/bin");
|
||||
sandbox.ln("usr/bin", "bin");
|
||||
sandbox.with_files(vec![
|
||||
Stub::FileWithContent("etc/shells", etc_shells_content.as_str()),
|
||||
Stub::MockExecutable("usr/bin/bash"),
|
||||
]);
|
||||
|
||||
let fallback_shells =
|
||||
AvailableShells::load_known_shells(&paths_to_search, Some(etc_shells.as_path()));
|
||||
|
||||
// We should expect there to be only one shell, with the path and id for that shell being
|
||||
// the canonical path to the executable.
|
||||
assert_eq!(
|
||||
fallback_shells,
|
||||
vec![AvailableShell {
|
||||
id: Some(format!("local:{}", usr_bin_bash.display())),
|
||||
state: Arc::new(Config::KnownLocal(LocalConfig {
|
||||
command: "bash".to_string(),
|
||||
executable_path: usr_bin_bash,
|
||||
shell_type: ShellType::Bash,
|
||||
}))
|
||||
}]
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_command_name_matches_known_shell() {
|
||||
let zsh_path = PathBuf::from("/bin/zsh");
|
||||
let pwsh_path = PathBuf::from("/opt/homebrew/bin/pwsh");
|
||||
let shells = make_available_shells(vec![
|
||||
AvailableShell::new_local_executable("zsh".to_string(), zsh_path.clone(), ShellType::Zsh),
|
||||
AvailableShell::new_local_executable(
|
||||
"pwsh".to_string(),
|
||||
pwsh_path.clone(),
|
||||
ShellType::PowerShell,
|
||||
),
|
||||
]);
|
||||
|
||||
let matched = shells
|
||||
.find_by_command_name("pwsh")
|
||||
.expect("should find pwsh by command name");
|
||||
assert_eq!(
|
||||
matched.id(),
|
||||
Some(format!("local:{}", pwsh_path.display()).as_str()),
|
||||
);
|
||||
|
||||
let matched = shells
|
||||
.find_by_command_name("zsh")
|
||||
.expect("should find zsh by command name");
|
||||
assert_eq!(
|
||||
matched.id(),
|
||||
Some(format!("local:{}", zsh_path.display()).as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_command_name_returns_none_for_unknown_name() {
|
||||
let shells = make_available_shells(vec![AvailableShell::new_local_executable(
|
||||
"zsh".to_string(),
|
||||
PathBuf::from("/bin/zsh"),
|
||||
ShellType::Zsh,
|
||||
)]);
|
||||
|
||||
assert!(shells.find_by_command_name("pwsh").is_none());
|
||||
assert!(shells.find_by_command_name("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_command_name_is_case_sensitive_on_unix() {
|
||||
// File names on Unix are case-sensitive, so an uppercase request should
|
||||
// not match a lowercase stored command.
|
||||
let shells = make_available_shells(vec![AvailableShell::new_local_executable(
|
||||
"pwsh".to_string(),
|
||||
PathBuf::from("/opt/homebrew/bin/pwsh"),
|
||||
ShellType::PowerShell,
|
||||
)]);
|
||||
|
||||
assert!(shells.find_by_command_name("pwsh").is_some());
|
||||
assert!(shells.find_by_command_name("PWSH").is_none());
|
||||
assert!(shells.find_by_command_name("PowerShell").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_command_name_skips_system_default() {
|
||||
// A SystemDefault entry should never be matched: it has no command name.
|
||||
let shells = make_available_shells(vec![
|
||||
AvailableShell::default(),
|
||||
AvailableShell::new_local_executable(
|
||||
"zsh".to_string(),
|
||||
PathBuf::from("/bin/zsh"),
|
||||
ShellType::Zsh,
|
||||
),
|
||||
]);
|
||||
|
||||
let matched = shells
|
||||
.find_by_command_name("zsh")
|
||||
.expect("should find zsh past the SystemDefault entry");
|
||||
assert_eq!(
|
||||
matched.id(),
|
||||
Some(format!("local:{}", PathBuf::from("/bin/zsh").display()).as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_command_name_matches_msys2_shell() {
|
||||
// Construct an MSYS2 shell directly so the `Config::MSYS2` arm of
|
||||
// `find_by_command_name` is exercised from any platform — its
|
||||
// `AvailableShell::new_msys2` constructor is gated to Windows, but the
|
||||
// match arm is platform-independent.
|
||||
let path = PathBuf::from("/tmp/msys64/usr/bin/bash-msys2");
|
||||
let msys2_shell = AvailableShell {
|
||||
id: Some(format!("msys2:{}", path.display())),
|
||||
state: Arc::new(Config::MSYS2(LocalConfig {
|
||||
command: "bash-msys2".to_string(),
|
||||
executable_path: path.clone(),
|
||||
shell_type: ShellType::Bash,
|
||||
})),
|
||||
};
|
||||
let shells = make_available_shells(vec![msys2_shell]);
|
||||
|
||||
let matched = shells
|
||||
.find_by_command_name("bash-msys2")
|
||||
.expect("should find MSYS2 shell by command name");
|
||||
assert_eq!(
|
||||
matched.id(),
|
||||
Some(format!("msys2:{}", path.display()).as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_name_matches_unix() {
|
||||
// Unix matching is a plain case-sensitive equality check: no case
|
||||
// folding, no `.exe` suffix handling.
|
||||
assert!(command_name_matches("pwsh", "pwsh", false));
|
||||
assert!(command_name_matches("zsh", "zsh", false));
|
||||
|
||||
assert!(!command_name_matches("pwsh", "PWSH", false));
|
||||
assert!(!command_name_matches("pwsh", "pwsh.exe", false));
|
||||
assert!(!command_name_matches("pwsh.exe", "pwsh", false));
|
||||
assert!(!command_name_matches("pwsh", "powershell", false));
|
||||
assert!(!command_name_matches("", "pwsh", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_name_matches_windows() {
|
||||
// Windows matching is case-insensitive and allows an optional trailing
|
||||
// `.exe` on either side.
|
||||
assert!(command_name_matches("pwsh", "pwsh", true));
|
||||
assert!(command_name_matches("pwsh", "PWSH", true));
|
||||
assert!(command_name_matches("PWSH", "pwsh", true));
|
||||
assert!(command_name_matches("PwSh", "pWsH", true));
|
||||
|
||||
// `.exe` is optional on either side.
|
||||
assert!(command_name_matches("pwsh.exe", "pwsh", true));
|
||||
assert!(command_name_matches("pwsh", "pwsh.exe", true));
|
||||
assert!(command_name_matches("pwsh.exe", "PWSH.EXE", true));
|
||||
assert!(command_name_matches("powershell.exe", "PowerShell", true));
|
||||
|
||||
// Distinct shells should not collide.
|
||||
assert!(!command_name_matches("pwsh", "powershell", true));
|
||||
assert!(!command_name_matches("pwsh.exe", "powershell.exe", true));
|
||||
assert!(!command_name_matches("bash.exe", "zsh", true));
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use regex_automata::hybrid::BuildError;
|
||||
use warp_editor::editor::NavigationKey;
|
||||
use warpui::elements::{Align, Dash};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::FocusContext;
|
||||
use warpui::{
|
||||
accessibility::{AccessibilityContent, WarpA11yRole},
|
||||
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 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 super::model::find::{FindConfig, RegexDFAs};
|
||||
|
||||
const FILTER_BLOCK_PLACEHOLDER_TEXT: &str = "Filter block output";
|
||||
|
||||
const BLOCK_FILTER_BAR_WIDTH: f32 = 380.;
|
||||
const BLOCK_FILTER_BAR_PADDING: f32 = 4.;
|
||||
const BLOCK_FILTER_EDITOR_PADDING: f32 = 6.;
|
||||
const BLOCK_FILTER_EDITOR_BORDER_RADIUS: f32 = 4.;
|
||||
const BLOCK_FILTER_BAR_MARGIN_BETWEEN_EDITORS: f32 = 4.;
|
||||
const BLOCK_FILTER_EDITOR_BORDER_WIDTH: f32 = 1.;
|
||||
const BLOCK_FILTER_FONT_SIZE: f32 = 12.;
|
||||
const BLOCK_FILTER_ICON_PADDING: f32 = 2.;
|
||||
const BLOCK_FILTER_ICON_MARGIN: f32 = 4.;
|
||||
const BLOCK_FILTER_ICON_CORNER_RADIUS: f32 = 4.;
|
||||
|
||||
const MAXIMUM_CONTEXT_LINES: u16 = 99;
|
||||
/// The maximum buffer length that we allow in the context line editor.
|
||||
const MAXIMUM_CONTEXT_LINE_EDITOR_BUFFER_LENGTH: usize = 2;
|
||||
pub type ContextLines = u16;
|
||||
pub const DEFAULT_CONTEXT_LINES_VALUE: ContextLines = 0;
|
||||
const CONTEXT_LINE_EDITOR_TOOLTIP_LABEL: &str = "Show context lines around matches";
|
||||
const REGEX_TOOLTIP_LABEL: &str = "Regex toggle";
|
||||
const CASE_SENSITIVITY_TOOLTIP_LABEL: &str = "Case sensitive search";
|
||||
const INVERT_FILTER_TOOLTIP_LABEL: &str = "Invert filter";
|
||||
|
||||
pub const BLOCK_FILTER_DOTTED_LINE_DASH: Dash = Dash {
|
||||
dash_length: 4.,
|
||||
gap_length: 4.,
|
||||
force_consistent_gap_length: false,
|
||||
};
|
||||
pub const BLOCK_FILTER_DOTTED_LINE_WIDTH: f32 = 1.;
|
||||
|
||||
/// View for the block filter editor.
|
||||
pub struct BlockFilterEditor {
|
||||
query_editor: ViewHandle<EditorView>,
|
||||
regex_enabled: bool,
|
||||
case_sensitivity_enabled: bool,
|
||||
invert_filter_enabled: bool,
|
||||
context_line_editor: ViewHandle<EditorView>,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
/// This keeps track of whether the previous editor event was select_all
|
||||
/// before any user edits were made.
|
||||
previous_editor_event_was_select_all: bool,
|
||||
/// The number of logical lines that match the current filter. Is None if no
|
||||
/// filter is currently active.
|
||||
num_matched_lines: Option<usize>,
|
||||
/// The number of context lines in the previous query.
|
||||
prev_num_context_lines: ContextLines,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
regex_mouse_state_handle: MouseStateHandle,
|
||||
case_sensitivity_mouse_state_handle: MouseStateHandle,
|
||||
context_line_editor_mouse_state_handle: MouseStateHandle,
|
||||
clear_filter_mouse_state_handle: MouseStateHandle,
|
||||
invert_filter_mouse_state_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockFilterQuery {
|
||||
pub query: String,
|
||||
/// The number of context lines to include above/below each matched line.
|
||||
pub num_context_lines: ContextLines,
|
||||
pub regex_enabled: bool,
|
||||
pub case_sensitivity_enabled: bool,
|
||||
pub invert_filter_enabled: bool,
|
||||
/// Only active queries will be applied to a block. Inactive queries will not
|
||||
/// be applied, but are used to store the previous filter state on a block.
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
pub enum OpenedFromClick {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
impl BlockFilterQuery {
|
||||
pub fn construct_dfas(&self) -> Result<RegexDFAs, Box<BuildError>> {
|
||||
RegexDFAs::new_with_config(
|
||||
self.query.as_str(),
|
||||
FindConfig {
|
||||
is_regex_enabled: self.regex_enabled,
|
||||
is_case_sensitive: self.case_sensitivity_enabled,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this block filter query will apply an active filter to
|
||||
/// the block. If false, this query will set the block into a non-filtered
|
||||
/// state.
|
||||
pub fn is_active_and_nonempty(&self) -> bool {
|
||||
!self.query.is_empty() && self.is_active
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(query: String) -> Self {
|
||||
Self {
|
||||
query,
|
||||
num_context_lines: 0,
|
||||
regex_enabled: false,
|
||||
case_sensitivity_enabled: false,
|
||||
invert_filter_enabled: false,
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BlockFilterEditorEvent {
|
||||
UpdateFilter(BlockFilterQuery),
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum BlockFilterEditorAction {
|
||||
Close,
|
||||
ToggleRegex,
|
||||
ToggleCaseSensitivity,
|
||||
ToggleInvertFilter,
|
||||
ClearQuery,
|
||||
}
|
||||
|
||||
impl Entity for BlockFilterEditor {
|
||||
type Event = BlockFilterEditorEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for BlockFilterEditor {
|
||||
type Action = BlockFilterEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &BlockFilterEditorAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
BlockFilterEditorAction::Close => self.close(ctx),
|
||||
BlockFilterEditorAction::ToggleRegex => self.toggle_regex(ctx),
|
||||
BlockFilterEditorAction::ToggleCaseSensitivity => self.toggle_case_sensitivity(ctx),
|
||||
BlockFilterEditorAction::ToggleInvertFilter => self.toggle_invert_filter(ctx),
|
||||
BlockFilterEditorAction::ClearQuery => self.clear_query(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_button_position_id(block: BlockIndex) -> String {
|
||||
format!("filter_button_for_block_{block}")
|
||||
}
|
||||
|
||||
impl BlockFilterEditor {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let query_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut editor = EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(Some(BLOCK_FILTER_FONT_SIZE), appearance),
|
||||
select_all_on_focus: true,
|
||||
clear_selections_on_blur: true,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.set_placeholder_text(FILTER_BLOCK_PLACEHOLDER_TEXT, ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| {
|
||||
me.handle_query_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let context_line_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut editor = EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(Some(BLOCK_FILTER_FONT_SIZE), appearance),
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
select_all_on_focus: true,
|
||||
clear_selections_on_blur: true,
|
||||
max_buffer_len: Some(MAXIMUM_CONTEXT_LINE_EDITOR_BUFFER_LENGTH),
|
||||
valid_input_type: ValidInputType::PositiveInteger,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.set_buffer_text(DEFAULT_CONTEXT_LINES_VALUE.to_string().as_str(), ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&context_line_editor, |me, _handle, event, ctx| {
|
||||
me.handle_context_line_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
query_editor,
|
||||
regex_enabled: false,
|
||||
case_sensitivity_enabled: false,
|
||||
invert_filter_enabled: false,
|
||||
mouse_state_handles: Default::default(),
|
||||
previous_editor_event_was_select_all: false,
|
||||
num_matched_lines: None,
|
||||
context_line_editor,
|
||||
prev_num_context_lines: DEFAULT_CONTEXT_LINES_VALUE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_and_set_filter(
|
||||
&mut self,
|
||||
active_filter_query: Option<BlockFilterQuery>,
|
||||
num_matched_lines: Option<usize>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(active_filter) = &active_filter_query {
|
||||
self.regex_enabled = active_filter.regex_enabled;
|
||||
self.case_sensitivity_enabled = active_filter.case_sensitivity_enabled;
|
||||
self.invert_filter_enabled = active_filter.invert_filter_enabled
|
||||
} else {
|
||||
self.reset_toggles();
|
||||
}
|
||||
self.num_matched_lines = num_matched_lines;
|
||||
|
||||
// If a previous active filter is present, we populate the editor with that number of context lines.
|
||||
// Otherwise, we use the default value.
|
||||
match active_filter_query {
|
||||
Some(active_filter) => {
|
||||
self.query_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(active_filter.query.as_str(), ctx);
|
||||
});
|
||||
self.context_line_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(
|
||||
active_filter.num_context_lines.to_string().as_str(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
None => self.context_line_editor.update(ctx, |editor, ctx| {
|
||||
editor
|
||||
.system_reset_buffer_text(DEFAULT_CONTEXT_LINES_VALUE.to_string().as_str(), ctx)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_toggles(&mut self) {
|
||||
self.regex_enabled = false;
|
||||
self.case_sensitivity_enabled = false;
|
||||
self.invert_filter_enabled = false;
|
||||
}
|
||||
|
||||
pub fn set_num_matched_lines(&mut self, num_matched_lines: Option<usize>) {
|
||||
self.num_matched_lines = num_matched_lines;
|
||||
}
|
||||
|
||||
fn query_editor_text(&self, ctx: &AppContext) -> String {
|
||||
self.query_editor.as_ref(ctx).buffer_text(ctx)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.query_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_clear_buffer(true, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(BlockFilterEditorEvent::Close);
|
||||
}
|
||||
|
||||
fn toggle_regex(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.regex_enabled = !self.regex_enabled;
|
||||
self.update_query(ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ToggleBlockFilterRegex {
|
||||
enabled: self.regex_enabled
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn toggle_case_sensitivity(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.case_sensitivity_enabled = !self.case_sensitivity_enabled;
|
||||
self.update_query(ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ToggleBlockFilterCaseSensitivity {
|
||||
enabled: self.case_sensitivity_enabled
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn toggle_invert_filter(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.invert_filter_enabled = !self.invert_filter_enabled;
|
||||
self.update_query(ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ToggleBlockFilterInvert {
|
||||
enabled: self.invert_filter_enabled
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends a block filter query update.
|
||||
fn update_query(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let num_context_lines = self
|
||||
.context_line_editor
|
||||
.as_ref(ctx)
|
||||
.buffer_text(ctx)
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_CONTEXT_LINES_VALUE);
|
||||
|
||||
ctx.emit(BlockFilterEditorEvent::UpdateFilter(BlockFilterQuery {
|
||||
query: self.query_editor_text(ctx),
|
||||
num_context_lines,
|
||||
regex_enabled: self.regex_enabled,
|
||||
case_sensitivity_enabled: self.case_sensitivity_enabled,
|
||||
invert_filter_enabled: self.invert_filter_enabled,
|
||||
is_active: true,
|
||||
}));
|
||||
|
||||
if num_context_lines != self.prev_num_context_lines {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::UpdateBlockFilterQueryContextLines { num_context_lines },
|
||||
ctx
|
||||
);
|
||||
self.prev_num_context_lines = num_context_lines;
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_query(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.query_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_clear_buffer(false, ctx);
|
||||
});
|
||||
self.update_query(ctx);
|
||||
ctx.focus(&self.query_editor);
|
||||
}
|
||||
|
||||
fn handle_query_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Edited(edit_origin) => {
|
||||
if matches!(edit_origin, EditOrigin::SystemEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_query(ctx);
|
||||
|
||||
// If the previous editor event was selecting all text and
|
||||
// the user now types in a non-empty query, then we should count this as an `UpdateBlockFilterQuery` event.
|
||||
if self.previous_editor_event_was_select_all
|
||||
&& !self.query_editor_text(ctx).is_empty()
|
||||
{
|
||||
send_telemetry_from_ctx!(TelemetryEvent::UpdateBlockFilterQuery, ctx);
|
||||
}
|
||||
self.previous_editor_event_was_select_all = false;
|
||||
}
|
||||
EditorEvent::Escape => self.close(ctx),
|
||||
EditorEvent::SelectionChanged => {
|
||||
self.query_editor.read(ctx, |editor, ctx| {
|
||||
let buffer_text = editor.buffer_text(ctx);
|
||||
let selected_text = editor.selected_text(ctx);
|
||||
if !buffer_text.is_empty() && buffer_text == selected_text {
|
||||
self.previous_editor_event_was_select_all = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
EditorEvent::Navigate(NavigationKey::Tab) => self.focus_other_editor(ctx),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_context_line_editor_event(
|
||||
&mut self,
|
||||
event: &EditorEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
EditorEvent::Edited(_) => self.update_query(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Up) => self.increase_context_line_count(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Down) => self.decrease_context_line_count(ctx),
|
||||
EditorEvent::Navigate(NavigationKey::Tab) => self.focus_other_editor(ctx),
|
||||
EditorEvent::Escape => self.close(ctx),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_other_editor(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.context_line_editor.is_focused(ctx) {
|
||||
ctx.focus(&self.query_editor);
|
||||
} else if self.query_editor.is_focused(ctx) {
|
||||
ctx.focus(&self.context_line_editor);
|
||||
}
|
||||
}
|
||||
|
||||
fn increase_context_line_count(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.context_line_editor.update(ctx, |editor, ctx| {
|
||||
let parsed_number = editor.buffer_text(ctx).parse::<u16>();
|
||||
let new_number = match parsed_number {
|
||||
Ok(prev_number) => prev_number.saturating_add(1).min(MAXIMUM_CONTEXT_LINES),
|
||||
Err(_) => DEFAULT_CONTEXT_LINES_VALUE,
|
||||
};
|
||||
editor.set_buffer_text(new_number.to_string().as_str(), ctx);
|
||||
})
|
||||
}
|
||||
|
||||
fn decrease_context_line_count(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.context_line_editor.update(ctx, |editor, ctx| {
|
||||
let parsed_number = editor.buffer_text(ctx).parse::<u16>();
|
||||
let new_number = match parsed_number {
|
||||
Ok(prev_number) => prev_number.saturating_sub(1),
|
||||
Err(_) => DEFAULT_CONTEXT_LINES_VALUE,
|
||||
};
|
||||
editor.set_buffer_text(new_number.to_string().as_str(), ctx);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_separator_line(&self, color: Fill, height: f32) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(Rect::new().with_background(color).finish())
|
||||
.with_width(1.)
|
||||
.with_height(height)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_hoverable_icon(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
icon: Icon,
|
||||
is_selected: bool,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
on_click_action: BlockFilterEditorAction,
|
||||
size: f32,
|
||||
tooltip_text: Option<&str>,
|
||||
) -> Box<dyn Element> {
|
||||
Hoverable::new(mouse_state_handle, |state| {
|
||||
let (border, background) = if is_selected {
|
||||
(
|
||||
Border::all(1.).with_border_fill(appearance.theme().accent()),
|
||||
appearance.theme().find_bar_button_selection_color(),
|
||||
)
|
||||
} else if state.is_hovered() {
|
||||
let hover_color = appearance.theme().foreground_button_color();
|
||||
(Border::all(1.).with_border_fill(hover_color), hover_color)
|
||||
} else {
|
||||
let transparent = Fill::Solid(ColorU::transparent_black());
|
||||
(Border::all(1.).with_border_fill(transparent), transparent)
|
||||
};
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
icon.to_warpui_icon(appearance.theme().active_ui_text_color())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(size)
|
||||
.with_width(size)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(BLOCK_FILTER_ICON_PADDING)
|
||||
.with_margin_right(BLOCK_FILTER_ICON_MARGIN)
|
||||
.with_border(border)
|
||||
.with_background(background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
BLOCK_FILTER_ICON_CORNER_RADIUS,
|
||||
)))
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(icon);
|
||||
if let (Some(tooltip_text), true) = (tooltip_text, state.is_hovered()) {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(tooltip_text.to_string())
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -5.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.on_click(move |ctx, _app, _| ctx.dispatch_typed_action(on_click_action))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for BlockFilterEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"BlockFilterEditor"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let editor_height = self
|
||||
.query_editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance);
|
||||
let mut query_editor_row =
|
||||
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let regex_icon = self.render_hoverable_icon(
|
||||
appearance,
|
||||
Icon::Regex,
|
||||
self.regex_enabled,
|
||||
self.mouse_state_handles.regex_mouse_state_handle.clone(),
|
||||
BlockFilterEditorAction::ToggleRegex,
|
||||
editor_height,
|
||||
Some(REGEX_TOOLTIP_LABEL),
|
||||
);
|
||||
let case_sensitive_icon = self.render_hoverable_icon(
|
||||
appearance,
|
||||
Icon::CaseSensitivity,
|
||||
self.case_sensitivity_enabled,
|
||||
self.mouse_state_handles
|
||||
.case_sensitivity_mouse_state_handle
|
||||
.clone(),
|
||||
BlockFilterEditorAction::ToggleCaseSensitivity,
|
||||
editor_height,
|
||||
Some(CASE_SENSITIVITY_TOOLTIP_LABEL),
|
||||
);
|
||||
let invert_filter_icon = self.render_hoverable_icon(
|
||||
appearance,
|
||||
Icon::Repeat,
|
||||
self.invert_filter_enabled,
|
||||
self.mouse_state_handles
|
||||
.invert_filter_mouse_state_handle
|
||||
.clone(),
|
||||
BlockFilterEditorAction::ToggleInvertFilter,
|
||||
editor_height,
|
||||
Some(INVERT_FILTER_TOOLTIP_LABEL),
|
||||
);
|
||||
|
||||
let query_editor = Shrinkable::new(
|
||||
1.,
|
||||
ConstrainedBox::new(Clipped::new(ChildView::new(&self.query_editor).finish()).finish())
|
||||
.with_height(editor_height)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
query_editor_row.add_child(query_editor);
|
||||
|
||||
// We only show the matched line count if there's an active, non-inverted
|
||||
// filter. In the future, we may want to show this for inverted filters
|
||||
// well, but this requires some changes to how we calculate matched lines.
|
||||
let matched_line_count = if let (Some(num_matched_lines), false) =
|
||||
(self.num_matched_lines, self.invert_filter_enabled)
|
||||
{
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
num_matched_lines.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(appearance.theme().disabled_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
} else {
|
||||
Empty::new().finish()
|
||||
};
|
||||
let clear_filter_icon = self.render_hoverable_icon(
|
||||
appearance,
|
||||
Icon::XCircle,
|
||||
false, // The "clear filter" icon should never be selected.
|
||||
self.mouse_state_handles
|
||||
.clear_filter_mouse_state_handle
|
||||
.clone(),
|
||||
BlockFilterEditorAction::ClearQuery,
|
||||
editor_height,
|
||||
None,
|
||||
);
|
||||
|
||||
query_editor_row.add_child(matched_line_count);
|
||||
query_editor_row.add_child(clear_filter_icon);
|
||||
query_editor_row.add_child(
|
||||
self.render_separator_line(appearance.theme().split_pane_border_color(), editor_height),
|
||||
);
|
||||
query_editor_row.add_child(regex_icon);
|
||||
query_editor_row.add_child(case_sensitive_icon);
|
||||
query_editor_row.add_child(invert_filter_icon);
|
||||
|
||||
let mut block_filter_row = Flex::row().with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(query_editor_row.finish())
|
||||
.with_padding_left(BLOCK_FILTER_EDITOR_PADDING)
|
||||
.with_vertical_padding(BLOCK_FILTER_EDITOR_PADDING)
|
||||
.with_background(appearance.theme().surface_1())
|
||||
.with_border(
|
||||
Border::all(BLOCK_FILTER_EDITOR_BORDER_WIDTH)
|
||||
.with_border_fill(appearance.theme().surface_3()),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
BLOCK_FILTER_EDITOR_BORDER_RADIUS,
|
||||
)))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let mut context_line_editor_row =
|
||||
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
let hoverable = Hoverable::new(
|
||||
self.mouse_state_handles
|
||||
.context_line_editor_mouse_state_handle
|
||||
.clone(),
|
||||
|state| {
|
||||
let context_line_icon = ConstrainedBox::new(
|
||||
Icon::DistributeSpacingVertical
|
||||
.to_warpui_icon(
|
||||
blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(editor_height - 4.)
|
||||
.with_height(editor_height - 4.);
|
||||
|
||||
let mut stack =
|
||||
Stack::new().with_child(Align::new(context_line_icon.finish()).finish());
|
||||
|
||||
if state.is_hovered() {
|
||||
let tool_tip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(CONTEXT_LINE_EDITOR_TOOLTIP_LABEL.to_string())
|
||||
.build()
|
||||
.finish();
|
||||
stack.add_positioned_child(
|
||||
tool_tip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -5.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
},
|
||||
);
|
||||
|
||||
context_line_editor_row.add_child(
|
||||
Container::new(hoverable.finish())
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let context_line_editor = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Clipped::new(ChildView::new(&self.context_line_editor).finish()).finish(),
|
||||
)
|
||||
.with_height(editor_height)
|
||||
.with_width(24.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
context_line_editor_row.add_child(context_line_editor);
|
||||
|
||||
block_filter_row.add_child(
|
||||
Container::new(context_line_editor_row.finish())
|
||||
.with_background(appearance.theme().surface_1())
|
||||
.with_padding_left(8.)
|
||||
.with_padding_right(4.)
|
||||
.with_vertical_padding(BLOCK_FILTER_EDITOR_PADDING)
|
||||
.with_border(
|
||||
Border::all(BLOCK_FILTER_EDITOR_BORDER_WIDTH)
|
||||
.with_border_fill(appearance.theme().surface_3()),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
BLOCK_FILTER_EDITOR_BORDER_RADIUS,
|
||||
)))
|
||||
.with_margin_left(BLOCK_FILTER_BAR_MARGIN_BETWEEN_EDITORS)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// On Windows, we need an extra 2 pixels of height to prevent tooltip text
|
||||
// from being clipped due to insufficient vertical space.
|
||||
let block_filter_bar_height = editor_height
|
||||
+ (2. * BLOCK_FILTER_EDITOR_PADDING)
|
||||
+ (2. * BLOCK_FILTER_BAR_PADDING)
|
||||
+ if cfg!(windows) { 2. } else { 0. };
|
||||
|
||||
let block_filter_bar = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Container::new(block_filter_row.finish())
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(block_filter_bar_height)
|
||||
.with_width(BLOCK_FILTER_BAR_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(BLOCK_FILTER_BAR_PADDING)
|
||||
.with_background(appearance.theme().surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
BLOCK_FILTER_EDITOR_BORDER_RADIUS,
|
||||
)))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish();
|
||||
|
||||
Dismiss::new(block_filter_bar)
|
||||
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(BlockFilterEditorAction::Close))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
self.query_editor.update(ctx, |editor, ctx| {
|
||||
editor.select_all(ctx);
|
||||
});
|
||||
ctx.focus(&self.query_editor);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn accessibility_contents(&self, _: &AppContext) -> Option<AccessibilityContent> {
|
||||
Some(AccessibilityContent::new(
|
||||
"Type searched phrase.",
|
||||
"Press escape to quit",
|
||||
WarpA11yRole::TextareaRole,
|
||||
))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
// Settings for controlling the behavior of the block list.
|
||||
define_settings_group!(BlockListSettings, settings: [
|
||||
show_jump_to_bottom_of_block_button: ShowJumpToBottomOfBlockButton {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.blocks.show_jump_to_bottom_of_block_button",
|
||||
description: "Whether to show the jump-to-bottom button in long command output.",
|
||||
},
|
||||
snackbar_enabled: SnackbarEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.snackbar_enabled",
|
||||
description: "Whether to show snackbar notifications.",
|
||||
}
|
||||
show_block_dividers: ShowBlockDividers {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.blocks.show_block_dividers",
|
||||
description: "Whether to show dividers between terminal blocks.",
|
||||
}
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::EnforceMinimumContrast;
|
||||
use crate::terminal::blockgrid_renderer::BlockGridParams;
|
||||
use crate::terminal::model::blockgrid::BlockGrid;
|
||||
use crate::terminal::model::grid::Dimensions;
|
||||
use crate::terminal::model::ObfuscateSecrets;
|
||||
use crate::terminal::{color, SizeInfo};
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::elements::{
|
||||
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, Point,
|
||||
SizeConstraint,
|
||||
};
|
||||
use warpui::event::DispatchedEvent;
|
||||
use warpui::geometry::rect::RectF;
|
||||
|
||||
use super::blockgrid_renderer::GridRenderParams;
|
||||
|
||||
pub struct BlockGridElement {
|
||||
block_grid: BlockGrid,
|
||||
block_grid_params: BlockGridParams,
|
||||
size: Vector2F,
|
||||
origin: Option<Point>,
|
||||
bounds: Option<RectF>,
|
||||
}
|
||||
|
||||
impl BlockGridElement {
|
||||
pub fn new(
|
||||
block_grid: &BlockGrid,
|
||||
appearance: &Appearance,
|
||||
enforce_minimum_contrast: EnforceMinimumContrast,
|
||||
obfuscate_secrets: ObfuscateSecrets,
|
||||
size_info: SizeInfo,
|
||||
) -> Self {
|
||||
let theme = appearance.theme();
|
||||
let cell_size = Vector2F::new(
|
||||
size_info.cell_width_px().as_f32(),
|
||||
size_info.cell_height_px().as_f32(),
|
||||
);
|
||||
let size = vec2f(
|
||||
block_grid.grid_handler().columns() as f32,
|
||||
block_grid.len_displayed() as f32,
|
||||
) * cell_size;
|
||||
Self {
|
||||
block_grid: block_grid.clone(),
|
||||
block_grid_params: BlockGridParams {
|
||||
grid_render_params: GridRenderParams {
|
||||
warp_theme: theme.clone(),
|
||||
font_family: appearance.monospace_font_family(),
|
||||
font_size: appearance.monospace_font_size(),
|
||||
font_weight: appearance.monospace_font_weight(),
|
||||
line_height_ratio: appearance.ui_builder().line_height_ratio(),
|
||||
enforce_minimum_contrast,
|
||||
obfuscate_secrets,
|
||||
size_info,
|
||||
cell_size,
|
||||
use_ligature_rendering: false,
|
||||
hide_cursor_cell: false,
|
||||
},
|
||||
colors: color::List::from(&color::Colors::from(theme.clone())),
|
||||
override_colors: color::OverrideList::empty(),
|
||||
bounds: Default::default(),
|
||||
},
|
||||
size,
|
||||
origin: None,
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ligature_rendering(mut self) -> Self {
|
||||
self.block_grid_params
|
||||
.grid_render_params
|
||||
.use_ligature_rendering = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the underlying text of the `BlockGrid`.
|
||||
pub fn text(&self) -> String {
|
||||
self.block_grid.contents_to_string(false, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for BlockGridElement {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: SizeConstraint,
|
||||
_ctx: &mut LayoutContext,
|
||||
_app: &AppContext,
|
||||
) -> Vector2F {
|
||||
self.size = self.size.min(constraint.max);
|
||||
self.size
|
||||
}
|
||||
|
||||
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
|
||||
|
||||
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
|
||||
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
|
||||
let bounds = RectF::new(origin, self.size);
|
||||
|
||||
self.block_grid_params.bounds = bounds;
|
||||
self.bounds = Some(bounds);
|
||||
self.block_grid
|
||||
.draw_with_default_params(origin, origin, &self.block_grid_params, ctx, app);
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_event: &DispatchedEvent,
|
||||
_ctx: &mut EventContext,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<Vector2F> {
|
||||
Some(self.size)
|
||||
}
|
||||
|
||||
fn origin(&self) -> Option<Point> {
|
||||
self.origin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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::WarpTheme;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Neg;
|
||||
use std::ops::RangeInclusive;
|
||||
use warpui::fonts::{FamilyId, Properties, Weight};
|
||||
use warpui::geometry::rect::RectF;
|
||||
use warpui::geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::{AppContext, Element, EntityId, PaintContext};
|
||||
|
||||
use super::model::ansi::{CursorShape, CursorStyle};
|
||||
use super::model::grid::RespectDisplayedOutput;
|
||||
use super::model::image_map::StoredImageMetadata;
|
||||
use super::model::SecretHandle;
|
||||
|
||||
pub struct GridRenderParams {
|
||||
pub warp_theme: WarpTheme,
|
||||
pub font_family: FamilyId,
|
||||
pub font_size: f32,
|
||||
pub font_weight: Weight,
|
||||
pub line_height_ratio: f32,
|
||||
pub enforce_minimum_contrast: EnforceMinimumContrast,
|
||||
pub obfuscate_secrets: ObfuscateSecrets,
|
||||
pub size_info: SizeInfo,
|
||||
pub cell_size: Vector2F,
|
||||
pub use_ligature_rendering: bool,
|
||||
/// When true, suppresses cursor rendering for CLI agents when rich input is open. For agents that draw their own cursor (SHOW_CURSOR off),
|
||||
/// the cursor cell is skipped. For agents that let Warp draw the cursor
|
||||
/// (SHOW_CURSOR on), the `draw_cursor` call and cursor contrast colouring
|
||||
/// are suppressed instead.
|
||||
pub hide_cursor_cell: bool,
|
||||
}
|
||||
|
||||
pub struct BlockGridParams {
|
||||
pub grid_render_params: GridRenderParams,
|
||||
pub colors: color::List,
|
||||
pub override_colors: color::OverrideList,
|
||||
pub bounds: RectF,
|
||||
}
|
||||
|
||||
impl BlockGrid {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw<'a>(
|
||||
&self,
|
||||
grid_origin: Vector2F,
|
||||
origin: Vector2F,
|
||||
glyphs: &mut CellGlyphCache,
|
||||
alpha: u8,
|
||||
highlighted_url: Option<&Link>,
|
||||
link_tool_tip: Option<&Link>,
|
||||
hovered_secret: Option<SecretHandle>,
|
||||
ordered_matches: Option<impl Iterator<Item = &'a RangeInclusive<Point>>>,
|
||||
focused_match_range: Option<&RangeInclusive<Point>>,
|
||||
properties: Properties,
|
||||
block_grid_params: &BlockGridParams,
|
||||
visible_cursor_shape: Option<CursorShape>,
|
||||
image_metadata: &HashMap<u32, StoredImageMetadata>,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let start_row = hidden_rows_above(
|
||||
grid_origin,
|
||||
origin,
|
||||
block_grid_params.grid_render_params.cell_size,
|
||||
);
|
||||
let hidden_rows_below = hidden_rows_below(
|
||||
grid_origin,
|
||||
origin,
|
||||
block_grid_params.grid_render_params.cell_size,
|
||||
block_grid_params.bounds,
|
||||
self.len_displayed(),
|
||||
);
|
||||
let end_row = self.len_displayed().saturating_sub(hidden_rows_below);
|
||||
|
||||
let grid = self.grid_handler();
|
||||
|
||||
let obfuscate_secrets = block_grid_params
|
||||
.grid_render_params
|
||||
.obfuscate_secrets
|
||||
.and(&grid.get_secret_obfuscation());
|
||||
render_grid(
|
||||
grid,
|
||||
start_row,
|
||||
end_row,
|
||||
&block_grid_params.colors,
|
||||
&block_grid_params.override_colors,
|
||||
&block_grid_params.grid_render_params.warp_theme,
|
||||
properties,
|
||||
block_grid_params.grid_render_params.font_family,
|
||||
block_grid_params.grid_render_params.font_size,
|
||||
block_grid_params.grid_render_params.line_height_ratio,
|
||||
block_grid_params.grid_render_params.cell_size,
|
||||
block_grid_params
|
||||
.grid_render_params
|
||||
.size_info
|
||||
.padding_x_px(),
|
||||
grid_origin,
|
||||
glyphs,
|
||||
alpha,
|
||||
highlighted_url,
|
||||
link_tool_tip,
|
||||
ordered_matches,
|
||||
focused_match_range,
|
||||
block_grid_params
|
||||
.grid_render_params
|
||||
.enforce_minimum_contrast,
|
||||
obfuscate_secrets,
|
||||
hovered_secret,
|
||||
block_grid_params.grid_render_params.use_ligature_rendering,
|
||||
visible_cursor_shape,
|
||||
RespectDisplayedOutput::Yes,
|
||||
image_metadata,
|
||||
None,
|
||||
block_grid_params.grid_render_params.hide_cursor_cell,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn draw_with_default_params(
|
||||
&self,
|
||||
grid_origin: Vector2F,
|
||||
origin: Vector2F,
|
||||
block_grid_params: &BlockGridParams,
|
||||
ctx: &mut PaintContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let mut glyphs = CellGlyphCache::default();
|
||||
self.draw(
|
||||
grid_origin,
|
||||
origin,
|
||||
&mut glyphs,
|
||||
255,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None::<std::iter::Empty<&RangeInclusive<Point>>>,
|
||||
None,
|
||||
Properties::default(),
|
||||
block_grid_params,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
ctx,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_cursor(
|
||||
&self,
|
||||
grid_origin: Vector2F,
|
||||
grid_render_params: &GridRenderParams,
|
||||
ctx: &mut PaintContext,
|
||||
terminal_view_id: EntityId,
|
||||
cursor_hint_text: Option<&mut Box<dyn Element>>,
|
||||
color: ColorU,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let cursor_style = self.cursor_style();
|
||||
let (cursor_display_point, is_cursor_on_wide_char, cursor_style, cursor_hint_text) =
|
||||
match self.cursor_display_point() {
|
||||
Some(CursorDisplayPoint::Visible(cursor_display_point)) => (
|
||||
cursor_display_point,
|
||||
self.grid_handler().is_cursor_on_wide_char(),
|
||||
cursor_style,
|
||||
cursor_hint_text,
|
||||
),
|
||||
Some(CursorDisplayPoint::HiddenCache(cursor_display_point)) => (
|
||||
cursor_display_point,
|
||||
false,
|
||||
CursorStyle {
|
||||
shape: CursorShape::Hidden,
|
||||
..cursor_style
|
||||
},
|
||||
None,
|
||||
),
|
||||
None => return,
|
||||
};
|
||||
|
||||
render_cursor(
|
||||
grid_render_params,
|
||||
cursor_display_point,
|
||||
is_cursor_on_wide_char,
|
||||
cursor_style,
|
||||
grid_render_params.size_info.padding_x_px(),
|
||||
grid_origin,
|
||||
color,
|
||||
ctx,
|
||||
terminal_view_id,
|
||||
cursor_hint_text,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn lines_to_pixels(line: usize, cell_size: Vector2F) -> Vector2F {
|
||||
vec2f(0., line as f32 * cell_size.y())
|
||||
}
|
||||
|
||||
fn pixels_to_lines(coord: Vector2F, cell_size: Vector2F) -> usize {
|
||||
(coord.y() / cell_size.y()) as usize
|
||||
}
|
||||
|
||||
/// Computes the number of rows that are not visible in the current grid above the viewport.
|
||||
fn hidden_rows_above(grid_origin: Vector2F, origin: Vector2F, cell_size: Vector2F) -> usize {
|
||||
((grid_origin.y() - origin.y()) / cell_size.y())
|
||||
.neg()
|
||||
.max(0.)
|
||||
.floor() as usize
|
||||
}
|
||||
|
||||
/// Computes the number of rows that are not visible in the grid below the viewport.
|
||||
fn hidden_rows_below(
|
||||
grid_origin: Vector2F,
|
||||
origin: Vector2F,
|
||||
cell_size: Vector2F,
|
||||
bounds: RectF,
|
||||
grid_len: usize,
|
||||
) -> usize {
|
||||
let grid_end = grid_origin + lines_to_pixels(grid_len, cell_size);
|
||||
let lower_left = origin.y() + bounds.height();
|
||||
pixels_to_lines(grid_end - lower_left, cell_size)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use memo_map::MemoMap;
|
||||
use warpui::{AppContext, AssetProvider, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
env_vars::EnvVar,
|
||||
terminal::{session_settings::SessionSettings, shell::ShellType},
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use super::{
|
||||
model::session::{BootstrapSessionType, SessionInfo},
|
||||
warpify::settings::{PIPENV_SUBSHELL_COMMAND_REGEX, POETRY_SUBSHELL_COMMAND_REGEX},
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
/// A memoized cache of the fully-interpolated boostrap 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();
|
||||
}
|
||||
|
||||
/// This can sometimes appear in the beginning of files. If it gets written into the PTY, it causes
|
||||
/// errors
|
||||
const BYTE_ORDER_MARK: &str = "\u{FEFF}";
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// This returns `true` for local Fish/Pwsh shells and local subshells spawned via `poetry shell`.
|
||||
///
|
||||
/// We use RC-file based bootstrap for local Fish shells because there is a long-standing bug which
|
||||
/// causes an explosion of formatting output when a command is longer than a screen height. (See
|
||||
/// https://github.com/fish-shell/fish-shell/issues/7296 for more) This multiplication of output
|
||||
/// makes our bootstrap take a long time, as we need to process all of that output (even though
|
||||
/// most of it is irrelevant). To avoid the impact on bootstrap time, we write the script to a
|
||||
/// temporary file and then source that file (which avoids writing the long script to the shell
|
||||
/// itself).
|
||||
///
|
||||
/// We use RC-file based bootstrap for PowerShell because chars written to the PTY get randomly
|
||||
/// ignored. See PLAT-757 in Linear.
|
||||
///
|
||||
/// We use RC-file based bootstrap for `poetry shell` subshells because the underlying library used
|
||||
/// to spawn a subshell by `poetry shell` uses blocking PTY reads and writes, which results in a
|
||||
/// deadlock when attempting to write the whole bootstrap script to the PTY; RC file-based
|
||||
/// bootstrap is the only known way to bootstrap such subshells successfully.
|
||||
///
|
||||
/// We use RC-file based bootstrap for MSYS2 because it has slow PTY throughput.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn should_use_rc_file_bootstrap_method(
|
||||
shell_type: ShellType,
|
||||
session_info: &SessionInfo,
|
||||
) -> bool {
|
||||
use super::ShellLaunchData;
|
||||
|
||||
let session_type = &session_info.session_type;
|
||||
match session_type {
|
||||
BootstrapSessionType::Local => {
|
||||
let subshell_initialization_info = session_info.subshell_info.as_ref();
|
||||
let is_poetry_subshell = subshell_initialization_info
|
||||
.as_ref()
|
||||
.map(|info| POETRY_SUBSHELL_COMMAND_REGEX.is_match(info.spawning_command.as_str()))
|
||||
.unwrap_or(false);
|
||||
let is_pipenv_subshell = subshell_initialization_info
|
||||
.as_ref()
|
||||
.map(|info| PIPENV_SUBSHELL_COMMAND_REGEX.is_match(info.spawning_command.as_str()))
|
||||
.unwrap_or(false);
|
||||
let is_msys2 = session_info
|
||||
.launch_data
|
||||
.as_ref()
|
||||
.is_some_and(|data| matches!(data, ShellLaunchData::MSYS2 { .. }));
|
||||
shell_type == ShellType::Fish
|
||||
|| shell_type == ShellType::PowerShell
|
||||
|| is_poetry_subshell
|
||||
|| ((is_pipenv_subshell
|
||||
|| (subshell_initialization_info.is_some() && cfg!(windows)))
|
||||
&& shell_type == ShellType::Zsh)
|
||||
|| is_msys2
|
||||
}
|
||||
BootstrapSessionType::WarpifiedRemote => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bootstrap script that should be used when initializing a shell
|
||||
/// of the given type.
|
||||
///
|
||||
/// This supports a very basic form of interpolation:
|
||||
///
|
||||
/// ```shell
|
||||
/// #include bundled/bootstrap/zsh_body.sh
|
||||
/// ```
|
||||
///
|
||||
/// The directive above instructs this function to replace that line with the
|
||||
/// contents of the file in our asset cache with the path `bundled/bootstrap/zsh_body.sh`.
|
||||
///
|
||||
/// At the moment, this interpolation is only performed for the top-level file,
|
||||
/// and is not performed recursively, but it would be useful to add such support
|
||||
/// in the future.
|
||||
pub fn script_for_shell(shell_type: ShellType, assets: &dyn AssetProvider) -> Cow<'static, [u8]> {
|
||||
let file = match shell_type {
|
||||
ShellType::Bash => "bash.sh",
|
||||
ShellType::Zsh => "zsh.sh",
|
||||
ShellType::Fish => "fish.sh",
|
||||
ShellType::PowerShell => "pwsh.ps1",
|
||||
};
|
||||
|
||||
BOOTSTRAP_CACHE
|
||||
.get_or_insert(&shell_type, || {
|
||||
let file_path = format!("bundled/bootstrap/{file}");
|
||||
let bootstrap = assets
|
||||
.get(&file_path)
|
||||
.unwrap_or_else(|_| panic!("failed to retrieve {file_path} from assets"));
|
||||
|
||||
// Interpret the file as UTF-8. We do this in an unchecked way
|
||||
// for performance, expecting that any issues here will be caught by
|
||||
// unit tests.
|
||||
let bootstrap = unsafe { String::from_utf8_unchecked(bootstrap.to_vec()) };
|
||||
|
||||
let additional_files = memo_map::MemoMap::new();
|
||||
|
||||
// Parse through the file, looking for any lines which start with
|
||||
// "#include", and replacing that line with the contents of the file
|
||||
// located at the path specified.
|
||||
//
|
||||
// We trim most leading and all trailing whitespace from lines, and
|
||||
// drop all empty lines and lines that only contain a comment. We
|
||||
// keep a single leading space on each line, if one exists, to
|
||||
// avoid interfering with histignorespace behavior.
|
||||
//
|
||||
// This minimizes the number of bytes we send over the pty during the
|
||||
// bootstrap process.
|
||||
fn trim_and_borrow_line(mut line: &str) -> Cow<'_, str> {
|
||||
let len = line.len();
|
||||
let trimmed_len = line.trim_start().len();
|
||||
if trimmed_len < len {
|
||||
let trimmed_chars = len - trimmed_len;
|
||||
line = &line[trimmed_chars - 1..];
|
||||
}
|
||||
Cow::Borrowed(line.trim_end())
|
||||
}
|
||||
let mut script = bootstrap
|
||||
.trim_start_matches(BYTE_ORDER_MARK)
|
||||
.split('\n')
|
||||
.map(trim_and_borrow_line)
|
||||
.flat_map(|line| {
|
||||
if let Some(path) = line.strip_prefix("#include ") {
|
||||
additional_files
|
||||
.get_or_insert(path, || {
|
||||
let data = assets.get(path).unwrap_or_else(|_| {
|
||||
panic!("failed to retrieve {path} from assets")
|
||||
});
|
||||
let data_string =
|
||||
unsafe { String::from_utf8_unchecked(data.to_vec()) };
|
||||
data_string.replace(
|
||||
"@@USING_CON_PTY_BOOLEAN@@",
|
||||
&(cfg!(windows).to_string()),
|
||||
)
|
||||
})
|
||||
.split('\n')
|
||||
.map(trim_and_borrow_line)
|
||||
.collect_vec()
|
||||
} else {
|
||||
vec![line]
|
||||
}
|
||||
})
|
||||
// Filter out empty lines and comments, to minimize the amount
|
||||
// of data we send over the pty during the bootstrap process.
|
||||
.filter(|line| {
|
||||
let line = line.trim_start();
|
||||
!(line.is_empty()
|
||||
|| line.starts_with('#')
|
||||
|| shell_type == ShellType::PowerShell
|
||||
&& line
|
||||
.starts_with("[Diagnostics.CodeAnalysis.SuppressMessageAttribute"))
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Make sure there's a newline at the end of the bootstrap script,
|
||||
// otherwise we'll never submit the final line to the shell.
|
||||
script.push('\n');
|
||||
script.into_bytes()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the command to be used to emit the InitShell hook for a new subshell session.
|
||||
///
|
||||
/// If `shell_type` is `Some()`, returns a shell type-specific command (e.g. valid command for
|
||||
/// bash, fish, or zsh). Otherwise, returns a shell type-agnostic command that emits the right
|
||||
/// `InitShell` hook based on the shell it is evaluated in.
|
||||
pub fn init_subshell_command(
|
||||
shell_type: Option<ShellType>,
|
||||
vars: &[EnvVar],
|
||||
ctx: &AppContext,
|
||||
) -> String {
|
||||
match shell_type {
|
||||
Some(shell_type) => {
|
||||
let subshell_script =
|
||||
init_subshell_script_for_shell(shell_type, &crate::ASSETS, vars, ctx);
|
||||
format!(r#" [ -z $WARP_BOOTSTRAPPED ] && eval '{subshell_script}'"#)
|
||||
}
|
||||
None => init_subshell_script_for_unknown_shell(&crate::ASSETS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the init subshell script for the given `shell_type` (e.g. the script that emits the
|
||||
/// subshell version of the InitShell DCS hook).
|
||||
///
|
||||
/// 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_shell(
|
||||
shell_type: ShellType,
|
||||
assets: &dyn AssetProvider,
|
||||
env_vars: &[EnvVar],
|
||||
ctx: &AppContext,
|
||||
) -> String {
|
||||
let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1;
|
||||
let honor_ps1_env_var_value = if honor_ps1 { "1" } else { "0" };
|
||||
|
||||
// Prepend environment variable settings to the script
|
||||
let env_setup_script = format!(
|
||||
"export WARP_HONOR_PS1={}; {}",
|
||||
honor_ps1_env_var_value,
|
||||
env_vars
|
||||
.iter()
|
||||
.map(|var| var.get_initialization_string(shell_type))
|
||||
.collect_vec()
|
||||
.join(" ")
|
||||
);
|
||||
|
||||
// Load and escape the shell-specific init script
|
||||
let shell_init_script = match shell_type {
|
||||
ShellType::Zsh => load_and_escape_script("bundled/bootstrap/zsh_init_subshell.sh", assets),
|
||||
ShellType::Bash => {
|
||||
load_and_escape_script("bundled/bootstrap/bash_init_subshell.sh", assets)
|
||||
}
|
||||
ShellType::Fish => {
|
||||
load_and_escape_script("bundled/bootstrap/fish_init_subshell.sh", assets)
|
||||
}
|
||||
// TODO(PLAT-750)
|
||||
ShellType::PowerShell => todo!(),
|
||||
};
|
||||
|
||||
// Combine the environment setup script with the shell-specific init script
|
||||
format!("{env_setup_script} {shell_init_script}")
|
||||
}
|
||||
|
||||
/// Returns the init subshell script for an unknown shell which detects the shell type.
|
||||
///
|
||||
/// 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 {
|
||||
// Load and escape the shell-specific init script
|
||||
load_and_escape_script("bundled/bootstrap/unknown_init_subshell.sh", assets)
|
||||
.replace("HOOK_NAME", "InitSubshell")
|
||||
}
|
||||
|
||||
/// Returns the raw init shell script for the given `shell_type`, without
|
||||
/// single-quote escaping. Suitable for passing as an environment variable
|
||||
/// where the caller controls the eval context (e.g. Docker sandbox init).
|
||||
///
|
||||
/// Gated on `unix` because the sole caller today is the Unix Docker
|
||||
/// sandbox spawn path (`local_tty::unix::prepare_docker_sandbox`); on
|
||||
/// Windows/wasm the function is dead code.
|
||||
#[cfg(unix)]
|
||||
pub fn raw_init_shell_script_for_shell(
|
||||
shell_type: ShellType,
|
||||
assets: &dyn AssetProvider,
|
||||
) -> String {
|
||||
let file = match shell_type {
|
||||
ShellType::Bash => "bundled/bootstrap/bash_init_shell.sh",
|
||||
ShellType::Zsh => "bundled/bootstrap/zsh_init_shell.sh",
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Returns the script in the file at `file_path` to be passed as a single-quoted argument in the
|
||||
/// shell (e.g. as a single quoted argument to `eval`).
|
||||
///
|
||||
/// The script is transformed in two ways:
|
||||
/// * Newlines are stripped and replaced with semi-colons
|
||||
/// * Single quotes are escaped (' is replaced with '"'"')
|
||||
/// * Lines starting with '#' are removed -- this enables use of comments in scripts. Note,
|
||||
/// however, that you still cannot use a 'partial line' comment, since this logic only considers
|
||||
/// whole lines.
|
||||
fn load_and_escape_script(file_path: &str, assets: &dyn AssetProvider) -> String {
|
||||
load_script(file_path, assets)
|
||||
.replace('\'', r#"'"'"'"#)
|
||||
.replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string()))
|
||||
}
|
||||
|
||||
fn load_script(file_path: &str, assets: &dyn AssetProvider) -> String {
|
||||
let script_bytes = assets
|
||||
.get(file_path)
|
||||
.unwrap_or_else(|_| panic!("Failed to retrieve {file_path} from assets"));
|
||||
|
||||
std::str::from_utf8(&script_bytes)
|
||||
.expect("InitShell script should be utf8 encoded.")
|
||||
.trim_start_matches(BYTE_ORDER_MARK)
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with('#') && !line.trim().is_empty())
|
||||
.join(";")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bootstrap_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,64 @@
|
||||
use super::*;
|
||||
|
||||
struct TestAssetProvider;
|
||||
|
||||
impl AssetProvider for TestAssetProvider {
|
||||
fn get(&self, path: &str) -> anyhow::Result<Cow<'_, [u8]>> {
|
||||
let content = match path {
|
||||
"bundled/bootstrap/bash.sh" => "#include hello_world",
|
||||
"bundled/bootstrap/fish.sh" => "# this is a comment\nthis_is_a_command",
|
||||
"bundled/bootstrap/zsh.sh" => {
|
||||
"asdf\n#include whitespace\n prepended whitespace\n\n\n"
|
||||
}
|
||||
"bundled/bootstrap/pwsh.ps1" => {
|
||||
r#"# This is a comment
|
||||
Write-Output 'Testing some output'
|
||||
function test1 {
|
||||
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'We actually need it')]
|
||||
param([string]$command)
|
||||
Invoke-Expression $command
|
||||
}"#
|
||||
}
|
||||
"hello_world" => "hello world!",
|
||||
"whitespace" => "no whitespace\n\n\n yes whitespace!",
|
||||
_ => anyhow::bail!("path not found in assets"),
|
||||
};
|
||||
Ok(Cow::Borrowed(content.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_directive() {
|
||||
assert_eq!(
|
||||
decode_script(&script_for_shell(ShellType::Bash, &TestAssetProvider)),
|
||||
"hello world!\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trims_comments() {
|
||||
assert_eq!(
|
||||
decode_script(&script_for_shell(ShellType::Fish, &TestAssetProvider)),
|
||||
"this_is_a_command\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trims_whitespace() {
|
||||
assert_eq!(
|
||||
decode_script(&script_for_shell(ShellType::Zsh, &TestAssetProvider)),
|
||||
"asdf\nno whitespace\n yes whitespace!\n prepended whitespace\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trims_powershell_specifics() {
|
||||
assert_eq!(
|
||||
decode_script(&script_for_shell(ShellType::PowerShell, &TestAssetProvider)),
|
||||
" Write-Output 'Testing some output'\n function test1 {\n param([string]$command)\n Invoke-Expression $command\n }\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn decode_script(bytes: &[u8]) -> &str {
|
||||
std::str::from_utf8(bytes).expect("should not fail to decode")
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
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 warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::Icon;
|
||||
use warp_graphql::billing::AddonCreditsOption;
|
||||
use warpui::elements::{
|
||||
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Expanded, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||
Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Radius, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent as _, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity as _, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::request_usage_model::{
|
||||
AIRequestUsageModel, AIRequestUsageModelEvent, BuyCreditsBannerDisplayState,
|
||||
};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::menu::MenuItemFields;
|
||||
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
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::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
use warp_graphql::error::BudgetExceededError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStates {
|
||||
buy_button: MouseStateHandle,
|
||||
close_button: MouseStateHandle,
|
||||
manage_billing_button: MouseStateHandle,
|
||||
auto_reload_checkbox: MouseStateHandle,
|
||||
auto_reload_info_icon: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct BuyCreditsBanner {
|
||||
mouse_states: MouseStates,
|
||||
denomination_dropdown: ViewHandle<Dropdown<Action>>,
|
||||
addon_credits_options: Vec<AddonCreditsOption>,
|
||||
selected_denomination_index: usize,
|
||||
purchase_addon_credits_loading: bool,
|
||||
should_display_banner: bool,
|
||||
billing_settings_hyperlink: HighlightedHyperlink,
|
||||
is_denomination_dropdown_open: bool,
|
||||
auto_reload_enabled: bool,
|
||||
banner_auto_reload_update_in_flight: bool,
|
||||
}
|
||||
|
||||
impl BuyCreditsBanner {
|
||||
pub fn is_denomination_dropdown_open(&self, _app: &AppContext) -> bool {
|
||||
self.is_denomination_dropdown_open
|
||||
}
|
||||
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&PricingInfoModel::handle(ctx), |me, _handle, event, ctx| {
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let PricingInfoModelEvent::PricingInfoUpdated = event {
|
||||
me.update_addon_credits_options(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&AIRequestUsageModel::handle(ctx),
|
||||
|_me, _handle, event, ctx| {
|
||||
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _handle, event, ctx| {
|
||||
me.handle_workspaces_event(event, ctx);
|
||||
});
|
||||
|
||||
let denomination_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx).with_drop_shadow();
|
||||
dropdown.set_top_bar_max_width(170.);
|
||||
dropdown.set_menu_width(225., ctx);
|
||||
dropdown
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&denomination_dropdown, |me, _, event, ctx| match event {
|
||||
crate::view_components::DropdownEvent::ToggleExpanded => {
|
||||
me.is_denomination_dropdown_open = true;
|
||||
ctx.notify();
|
||||
}
|
||||
crate::view_components::DropdownEvent::Close => {
|
||||
me.is_denomination_dropdown_open = false;
|
||||
ctx.emit(BuyCreditsBannerEvent::RefocusInput);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
let mut me = Self {
|
||||
mouse_states: Default::default(),
|
||||
denomination_dropdown,
|
||||
addon_credits_options: Default::default(),
|
||||
selected_denomination_index: 0,
|
||||
purchase_addon_credits_loading: false,
|
||||
should_display_banner: false,
|
||||
billing_settings_hyperlink: HighlightedHyperlink::default(),
|
||||
is_denomination_dropdown_open: false,
|
||||
auto_reload_enabled: false,
|
||||
banner_auto_reload_update_in_flight: false,
|
||||
};
|
||||
me.update_addon_credits_options(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
fn handle_workspaces_event(
|
||||
&mut self,
|
||||
event: &UserWorkspacesEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
UserWorkspacesEvent::PurchaseAddonCreditsSuccess => {
|
||||
self.purchase_addon_credits_loading = false;
|
||||
|
||||
let banner_toggle_flag_enabled =
|
||||
FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled();
|
||||
let post_purchase_modal_flag_enabled =
|
||||
FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled();
|
||||
|
||||
let selected_credits = self
|
||||
.addon_credits_options
|
||||
.get(self.selected_denomination_index)
|
||||
.map(|option| option.credits);
|
||||
|
||||
// Things we always do:
|
||||
// - emit telemetry
|
||||
// - hide banner
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::OutOfCreditsBannerClosed {
|
||||
action: OutOfCreditsBannerAction::CreditsPurchased,
|
||||
selected_credits,
|
||||
auto_reload_checkbox_enabled: self.auto_reload_enabled,
|
||||
banner_toggle_flag_enabled,
|
||||
post_purchase_modal_flag_enabled,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.should_display_banner = false;
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |ai_request_usage_model, ctx| {
|
||||
ai_request_usage_model.dismiss_buy_credits_banner(ctx);
|
||||
});
|
||||
|
||||
// Experiment-specific behavior:
|
||||
// - 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 {
|
||||
self.banner_auto_reload_update_in_flight = true;
|
||||
|
||||
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.update_addon_credits_settings(
|
||||
team_uid,
|
||||
Some(true),
|
||||
None, // Don't change monthly spend limit
|
||||
selected_credits,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if 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),
|
||||
});
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
UserWorkspacesEvent::PurchaseAddonCreditsRejected(err) => {
|
||||
self.purchase_addon_credits_loading = false;
|
||||
self.banner_auto_reload_update_in_flight = false;
|
||||
|
||||
if err.downcast_ref::<BudgetExceededError>().is_some() {
|
||||
self.should_display_banner = true;
|
||||
} else {
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |ai_request_usage_model, ctx| {
|
||||
ai_request_usage_model.dismiss_buy_credits_banner(ctx);
|
||||
});
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
|
||||
if self.banner_auto_reload_update_in_flight {
|
||||
self.banner_auto_reload_update_in_flight = false;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_) => {
|
||||
if self.banner_auto_reload_update_in_flight {
|
||||
self.banner_auto_reload_update_in_flight = false;
|
||||
ctx.emit(BuyCreditsBannerEvent::ShowAutoReloadError {
|
||||
error_message: "Failed to enable auto-reload for your team. Please try again in Settings > Billing and Usage.",
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_auto_reload_checkbox(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let check_color = theme.background().into_solid();
|
||||
let auto_reload_enabled = self.auto_reload_enabled;
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(
|
||||
self.mouse_states.auto_reload_checkbox.clone(),
|
||||
Some(appearance.ui_font_size()),
|
||||
)
|
||||
.check(auto_reload_enabled)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(check_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(Action::ToggleAutoReload);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let sub_text_color = theme.sub_text_color(theme.surface_1());
|
||||
|
||||
let label = Text::new_inline("Auto reload", appearance.ui_font_family(), 12.)
|
||||
.with_color(sub_text_color.into())
|
||||
.finish();
|
||||
|
||||
// Get the selected amount for the tooltip
|
||||
let selected_credits = self
|
||||
.addon_credits_options
|
||||
.get(self.selected_denomination_index)
|
||||
.map(|option| option.credits)
|
||||
.unwrap_or(0);
|
||||
|
||||
let tooltip_text = format!(
|
||||
"When enabled, auto reload will purchase {} credits when your credit balance gets low",
|
||||
selected_credits
|
||||
);
|
||||
|
||||
// Create info icon with a custom sub_text_color & mouse cursor (i.e. as opposed to using IconWithTooltip)
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let info_icon = Hoverable::new(
|
||||
self.mouse_states.auto_reload_info_icon.clone(),
|
||||
move |state| {
|
||||
let info_icon_element = Container::new(
|
||||
ConstrainedBox::new(
|
||||
WarpUiIcon::new("bundled/svg/info.svg", sub_text_color).finish(),
|
||||
)
|
||||
.with_width(13.)
|
||||
.with_height(13.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new().with_child(info_icon_element);
|
||||
if state.is_hovered() {
|
||||
let tool_tip = ui_builder.tool_tip(tooltip_text.clone()).build();
|
||||
stack.add_positioned_child(
|
||||
tool_tip.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -3.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopMiddle,
|
||||
ChildAnchor::BottomMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
},
|
||||
)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_child(Container::new(checkbox).with_margin_right(4.).finish())
|
||||
.with_child(label)
|
||||
.with_child(
|
||||
Container::new(info_icon)
|
||||
.with_margin_left(4.)
|
||||
.with_margin_bottom(1.)
|
||||
.finish(),
|
||||
)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn update_addon_credits_options(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.addon_credits_options = PricingInfoModel::as_ref(ctx)
|
||||
.addon_credits_options()
|
||||
.map(|opts| opts.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let base_rate = self
|
||||
.addon_credits_options
|
||||
.first()
|
||||
.map_or(0., |option| option.rate());
|
||||
let items = self
|
||||
.addon_credits_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, option)| {
|
||||
let primary_text = format!(
|
||||
"${:.0} / {} credits",
|
||||
option.price_usd_cents as f32 / 100.,
|
||||
option.credits
|
||||
);
|
||||
let discount_percent = if base_rate > 0.0 {
|
||||
let actual_rate = option.rate();
|
||||
((base_rate - actual_rate) / base_rate * 100.0).round() as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if discount_percent > 0 {
|
||||
MenuItemFields::new_with_custom_label(
|
||||
Arc::new(enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
|
||||
let text_color = appearance.theme().main_text_color(
|
||||
if is_selected || is_hovered {
|
||||
appearance.theme().accent()
|
||||
} else {
|
||||
appearance.theme().surface_1()
|
||||
}
|
||||
);
|
||||
let main_text = Text::new_inline(
|
||||
primary_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(text_color.into())
|
||||
.finish();
|
||||
|
||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(main_text)
|
||||
.with_child(discount_badge)
|
||||
.finish()
|
||||
})),
|
||||
Some(primary_text)
|
||||
)
|
||||
.with_on_select_action(Action::SelectDenomination(index).into())
|
||||
.into_item()
|
||||
} else {
|
||||
MenuItemFields::new(primary_text.clone())
|
||||
.with_on_select_action(Action::SelectDenomination(index).into())
|
||||
.into_item()
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
self.denomination_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_rich_items(items, ctx);
|
||||
dropdown.set_selected_by_index(0, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_auto_reload_blocked(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let alert_icon = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::AlertCircle
|
||||
.to_warpui_icon(theme.foreground())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let current_team = UserWorkspaces::as_ref(app).current_team();
|
||||
let has_admin_permissions = auth_state
|
||||
.user_email()
|
||||
.zip(current_team)
|
||||
.map(|(email, team)| team.has_admin_permissions(&email))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Banner text with title and description based on admin status
|
||||
let banner_description = if has_admin_permissions {
|
||||
"Your monthly spend limit has been reached. Increase it to continue."
|
||||
} else {
|
||||
"Contact a team admin to increase monthly limit."
|
||||
};
|
||||
|
||||
let banner_text = Flex::column()
|
||||
.with_children([
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph("Monthly limit reached")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph(banner_description)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(theme.sub_text_color(theme.surface_1()).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
])
|
||||
.finish();
|
||||
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(16., self.mouse_states.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(Action::Close))
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
alert_icon,
|
||||
Shrinkable::new(1., Align::new(banner_text).left().finish()).finish(),
|
||||
]);
|
||||
|
||||
// Only show manage billing button for admins
|
||||
if has_admin_permissions {
|
||||
let manage_billing_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.mouse_states.manage_billing_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Semibold),
|
||||
padding: Some(Coords {
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
left: 8.,
|
||||
right: 8.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label("Manage billing".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(Action::ManageBilling);
|
||||
})
|
||||
.finish();
|
||||
|
||||
content.add_child(
|
||||
Container::new(manage_billing_button)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
content.add_child(close_button);
|
||||
|
||||
Container::new(content.finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_background_color(theme.surface_1().into())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(12.)
|
||||
.with_horizontal_margin(8.)
|
||||
.with_drop_shadow(DropShadow::new_with_standard_offset_and_spread(
|
||||
ColorU::new(0, 0, 0, 48),
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_out_of_credits(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
// When the auto-reload checkbox is present, we need more horizontal space before we can
|
||||
// fit everything on one row.
|
||||
const STACKED_LAYOUT_MAX_WIDTH_WITH_AUTO_RELOAD: f32 = 520.;
|
||||
const STACKED_LAYOUT_MAX_WIDTH_WITHOUT_AUTO_RELOAD: f32 = 400.;
|
||||
|
||||
let theme = appearance.theme();
|
||||
|
||||
let make_alert_icon = |margin_bottom: f32| {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::AlertCircle
|
||||
.to_warpui_icon(theme.foreground())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.with_margin_bottom(margin_bottom)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let current_team = UserWorkspaces::as_ref(app).current_team();
|
||||
let has_admin_permissions = auth_state
|
||||
.user_email()
|
||||
.zip(current_team)
|
||||
.map(|(email, team)| team.has_admin_permissions(&email))
|
||||
.unwrap_or_default();
|
||||
let delinquent_due_to_payment_issue = current_team
|
||||
.is_some_and(|team| team.billing_metadata.is_delinquent_due_to_payment_issue());
|
||||
let auto_reload_banner_toggle_ff =
|
||||
FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled();
|
||||
|
||||
// Check if user has reached their monthly addon credits limit
|
||||
let current_workspace = UserWorkspaces::as_ref(app).current_workspace();
|
||||
let is_at_monthly_limit = current_workspace
|
||||
.map(|workspace| workspace.is_at_addon_credits_monthly_limit())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if the selected purchase would reach/exceed the monthly limit
|
||||
let selected_option = self
|
||||
.addon_credits_options
|
||||
.get(self.selected_denomination_index);
|
||||
let would_purchase_exceed_limit = current_workspace
|
||||
.zip(selected_option)
|
||||
.map(|(workspace, option)| {
|
||||
workspace.would_addon_purchase_reach_limit(option.price_usd_cents)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let make_banner_text = || {
|
||||
let mut banner_text_children = vec![appearance
|
||||
.ui_builder()
|
||||
.paragraph("Out of credits")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()];
|
||||
|
||||
// Show different message based on whether purchase would exceed limit
|
||||
if is_at_monthly_limit || would_purchase_exceed_limit {
|
||||
// Create formatted text with clickable hyperlink
|
||||
let warning_text_fragments = vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Purchasing these credits would take you over your monthly spend limit. ",
|
||||
),
|
||||
FormattedTextFragment::hyperlink_action("Increase it", Action::ManageBilling),
|
||||
FormattedTextFragment::plain_text(" to continue."),
|
||||
];
|
||||
|
||||
let formatted_warning = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(warning_text_fragments)]),
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
theme.sub_text_color(theme.surface_1()).into(),
|
||||
self.billing_settings_hyperlink.clone(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(
|
||||
|hyperlink_lens, event, _ctx| match hyperlink_lens {
|
||||
warpui::elements::HyperlinkLens::Url(_url) => {}
|
||||
warpui::elements::HyperlinkLens::Action(action_ref) => {
|
||||
if let Some(action) = action_ref.as_any().downcast_ref::<Action>() {
|
||||
event.dispatch_typed_action(action.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.finish();
|
||||
|
||||
banner_text_children.push(formatted_warning);
|
||||
} else {
|
||||
// Default message when not at limit
|
||||
let banner_description = if has_admin_permissions {
|
||||
"Add more credits to your account to continue using Oz agents."
|
||||
} else {
|
||||
"Contact a team admin to purchase more credits to continue."
|
||||
};
|
||||
|
||||
banner_text_children.push(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.paragraph(banner_description)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(theme.sub_text_color(theme.surface_1()).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let banner_text =
|
||||
Container::new(Flex::column().with_children(banner_text_children).finish())
|
||||
.with_margin_right(8.)
|
||||
.finish();
|
||||
|
||||
Expanded::new(
|
||||
1.,
|
||||
Align::new(ConstrainedBox::new(banner_text).finish())
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_buy_button = || {
|
||||
let team_uid = current_team.map(|team| team.uid);
|
||||
|
||||
let buy_button_disabled = self.purchase_addon_credits_loading
|
||||
|| delinquent_due_to_payment_issue
|
||||
|| is_at_monthly_limit
|
||||
|| would_purchase_exceed_limit;
|
||||
|
||||
let button_text = if self.purchase_addon_credits_loading {
|
||||
"Buying…".to_string()
|
||||
} else {
|
||||
"Buy".to_string()
|
||||
};
|
||||
|
||||
let button_font_color = buy_button_disabled.then_some(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(appearance.theme().surface_3())
|
||||
.into(),
|
||||
);
|
||||
let button_bg_color =
|
||||
buy_button_disabled.then_some(appearance.theme().surface_3().into());
|
||||
let button_border = buy_button_disabled.then_some(ColorU::transparent_black().into());
|
||||
|
||||
let mut buy_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.mouse_states.buy_button.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Semibold),
|
||||
padding: Some(Coords {
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
left: 8.,
|
||||
right: 8.,
|
||||
}),
|
||||
font_color: button_font_color,
|
||||
background: button_bg_color,
|
||||
border_color: button_border,
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label(button_text)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
if let Some(team_uid) = team_uid {
|
||||
ctx.dispatch_typed_action(Action::PurchaseAddonCredits { team_uid });
|
||||
}
|
||||
});
|
||||
|
||||
if buy_button_disabled {
|
||||
buy_button = buy_button.disable();
|
||||
}
|
||||
|
||||
buy_button.finish()
|
||||
};
|
||||
|
||||
let make_close_button = || {
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(16., self.mouse_states.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(Action::Close))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_admin_controls_children = || {
|
||||
let denomination_dropdown = ChildView::new(&self.denomination_dropdown).finish();
|
||||
let buy_button = make_buy_button();
|
||||
|
||||
let mut children = Vec::new();
|
||||
|
||||
if auto_reload_banner_toggle_ff {
|
||||
children.push(
|
||||
Container::new(self.render_auto_reload_checkbox(appearance))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
children.extend([
|
||||
Container::new(denomination_dropdown)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
Container::new(buy_button).finish(),
|
||||
]);
|
||||
|
||||
children
|
||||
};
|
||||
|
||||
let make_admin_controls_row = || {
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children(make_admin_controls_children())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let make_row_layout = || {
|
||||
let mut content = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([make_alert_icon(12.), make_banner_text()]);
|
||||
|
||||
if has_admin_permissions {
|
||||
content.add_children(make_admin_controls_children());
|
||||
}
|
||||
|
||||
content.add_child(make_close_button());
|
||||
content.finish()
|
||||
};
|
||||
|
||||
let make_stacked_layout = || {
|
||||
let top_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([make_alert_icon(0.), make_banner_text(), make_close_button()])
|
||||
.finish();
|
||||
|
||||
let bottom_row = Container::new(make_admin_controls_row())
|
||||
.with_margin_top(8.)
|
||||
.finish();
|
||||
|
||||
Flex::column().with_children([top_row, bottom_row]).finish()
|
||||
};
|
||||
|
||||
let content = if has_admin_permissions {
|
||||
let stacked_breakpoint = if auto_reload_banner_toggle_ff {
|
||||
STACKED_LAYOUT_MAX_WIDTH_WITH_AUTO_RELOAD
|
||||
} else {
|
||||
STACKED_LAYOUT_MAX_WIDTH_WITHOUT_AUTO_RELOAD
|
||||
};
|
||||
|
||||
SizeConstraintSwitch::new(
|
||||
make_row_layout(),
|
||||
vec![(
|
||||
SizeConstraintCondition::WidthLessThan(stacked_breakpoint),
|
||||
make_stacked_layout(),
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
make_row_layout()
|
||||
};
|
||||
|
||||
// The dropdown adds some margin so we need to use less padding.
|
||||
let vertical_padding = if has_admin_permissions { 6. } else { 12. };
|
||||
|
||||
Container::new(content)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_background_color(theme.surface_1().into())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(vertical_padding)
|
||||
.with_horizontal_margin(8.)
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BuyCreditsBannerEvent {
|
||||
OpenBillingAndUsage,
|
||||
RefocusInput,
|
||||
OpenAutoReloadModal { purchased_credits: i32 },
|
||||
ShowAutoReloadError { error_message: &'static str },
|
||||
}
|
||||
|
||||
impl Entity for BuyCreditsBanner {
|
||||
type Event = BuyCreditsBannerEvent;
|
||||
}
|
||||
|
||||
impl View for BuyCreditsBanner {
|
||||
fn ui_name() -> &'static str {
|
||||
"BuyCreditsBanner"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let ai_request_usage = AIRequestUsageModel::as_ref(app);
|
||||
|
||||
// Override with spend limit error if set (from failed purchase attempt)
|
||||
let display_state = if self.should_display_banner {
|
||||
BuyCreditsBannerDisplayState::MonthlyLimitReached
|
||||
} else {
|
||||
ai_request_usage.compute_buy_addon_credits_banner_display_state(app)
|
||||
};
|
||||
|
||||
match display_state {
|
||||
BuyCreditsBannerDisplayState::Hidden => {
|
||||
Container::new(warpui::elements::Empty::new().finish()).finish()
|
||||
}
|
||||
BuyCreditsBannerDisplayState::OutOfCredits => {
|
||||
self.render_out_of_credits(appearance, app)
|
||||
}
|
||||
BuyCreditsBannerDisplayState::MonthlyLimitReached => {
|
||||
self.render_auto_reload_blocked(appearance, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Action {
|
||||
SelectDenomination(usize),
|
||||
Close,
|
||||
PurchaseAddonCredits { team_uid: ServerId },
|
||||
ManageBilling,
|
||||
ToggleAutoReload,
|
||||
}
|
||||
|
||||
impl warpui::TypedActionView for BuyCreditsBanner {
|
||||
type Action = Action;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Action::SelectDenomination(index) => {
|
||||
self.selected_denomination_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
Action::Close => {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::OutOfCreditsBannerClosed {
|
||||
action: OutOfCreditsBannerAction::Dismissed,
|
||||
selected_credits: None,
|
||||
auto_reload_checkbox_enabled: self.auto_reload_enabled,
|
||||
banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle
|
||||
.is_enabled(),
|
||||
post_purchase_modal_flag_enabled:
|
||||
FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
self.should_display_banner = false;
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.dismiss_buy_credits_banner(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Action::ManageBilling => {
|
||||
ctx.emit(BuyCreditsBannerEvent::OpenBillingAndUsage);
|
||||
self.should_display_banner = false;
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.dismiss_buy_credits_banner(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
Action::PurchaseAddonCredits { team_uid } => {
|
||||
if let Some(option) = self
|
||||
.addon_credits_options
|
||||
.get(self.selected_denomination_index)
|
||||
{
|
||||
let credits = option.credits;
|
||||
self.purchase_addon_credits_loading = true;
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.purchase_addon_credits(*team_uid, credits, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
Action::ToggleAutoReload => {
|
||||
self.auto_reload_enabled = !self.auto_reload_enabled;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! CLI agent detection and configuration.
|
||||
//!
|
||||
//! This module provides types for detecting and working with CLI-based AI agents
|
||||
//! like Claude Code, Gemini CLI, Codex, Amp, and Droid.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use ai::skills::SkillProvider;
|
||||
use enum_iterator::Sequence;
|
||||
use markdown_parser::parse_markdown;
|
||||
use pathfinder_color::ColorU;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
use warp_editor::content::{buffer::Buffer, markdown::MarkdownStyle};
|
||||
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::{AgentReviewCommentBatch, DiffSetHunk};
|
||||
use crate::ai::blocklist::CLAUDE_ORANGE;
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::AttachedReviewCommentTarget;
|
||||
use crate::server::telemetry::CLIAgentType;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use warp_completer::parsers::simple::top_level_command;
|
||||
use warp_util::path::EscapeChar;
|
||||
|
||||
/// UID for the Uber team.
|
||||
/// See https://warp.metabaseapp.com/dashboard/1454?team_id=46347
|
||||
const UBER_TEAM_UID: &str = "BdVbYjy9LRZcZrYBemSfAF";
|
||||
|
||||
/// Gemini brand blue color
|
||||
pub(crate) const GEMINI_BLUE: ColorU = ColorU {
|
||||
r: 66,
|
||||
g: 133,
|
||||
b: 244,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// OpenAI brand color (dark gray/black)
|
||||
const OPENAI_COLOR: ColorU = ColorU {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Amp brand color (#F34E3F)
|
||||
const AMP_COLOR: ColorU = ColorU {
|
||||
r: 243,
|
||||
g: 78,
|
||||
b: 63,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Droid brand color (white)
|
||||
const DROID_COLOR: ColorU = ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// OpenCode brand color (gray, used for contrast calculation only)
|
||||
const OPENCODE_COLOR: ColorU = ColorU {
|
||||
r: 128,
|
||||
g: 128,
|
||||
b: 128,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Copilot brand color (Copilot purple selected from https://brand.github.com/brand-identity/copilot)
|
||||
const COPILOT_COLOR: ColorU = ColorU {
|
||||
r: 133,
|
||||
g: 52,
|
||||
b: 243,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Pi brand color (white, monochrome logo)
|
||||
const PI_COLOR: ColorU = ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Auggie brand color (white, monochrome logo)
|
||||
const AUGGIE_COLOR: ColorU = ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Cursor brand color (#26251E, from official brand assets)
|
||||
const CURSOR_COLOR: ColorU = ColorU {
|
||||
r: 38,
|
||||
g: 37,
|
||||
b: 30,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
/// Represents a CLI agent (e.g., Claude Code, Gemini CLI, Codex, Amp, Droid, OpenCode, Copilot, Pi, Auggie, Cursor)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Sequence, Serialize, Deserialize)]
|
||||
pub enum CLIAgent {
|
||||
Claude,
|
||||
Gemini,
|
||||
Codex,
|
||||
Amp,
|
||||
Droid,
|
||||
OpenCode,
|
||||
Copilot,
|
||||
Pi,
|
||||
Auggie,
|
||||
CursorCli,
|
||||
/// Represents an unknown/custom CLI agent matched by user-configured regex patterns.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CLIAgent {
|
||||
/// The command prefix used to invoke this CLI agent.
|
||||
pub fn command_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Claude => "claude",
|
||||
CLIAgent::Gemini => "gemini",
|
||||
CLIAgent::Codex => "codex",
|
||||
CLIAgent::Amp => "amp",
|
||||
CLIAgent::Droid => "droid",
|
||||
CLIAgent::OpenCode => "opencode",
|
||||
CLIAgent::Copilot => "copilot",
|
||||
CLIAgent::Pi => "pi",
|
||||
CLIAgent::Auggie => "auggie",
|
||||
CLIAgent::CursorCli => "agent",
|
||||
CLIAgent::Unknown => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized version of the CLIAgent name (e.g. "Claude", "Gemini"). Used for the
|
||||
/// session-sharing protocol's opaque `cli_agent` string field.
|
||||
pub fn to_serialized_name(&self) -> String {
|
||||
serde_json::to_value(self)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Inverse of `to_serialized_name`. Falls back to `Unknown`.
|
||||
pub fn from_serialized_name(name: &str) -> CLIAgent {
|
||||
serde_json::from_value(name.into()).unwrap_or(CLIAgent::Unknown)
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Claude => "Claude Code",
|
||||
CLIAgent::Gemini => "Gemini",
|
||||
CLIAgent::Codex => "Codex",
|
||||
CLIAgent::Amp => "Amp",
|
||||
CLIAgent::Droid => "Droid",
|
||||
CLIAgent::OpenCode => "OpenCode",
|
||||
CLIAgent::Copilot => "Copilot",
|
||||
CLIAgent::Pi => "Pi",
|
||||
CLIAgent::Auggie => "Auggie",
|
||||
CLIAgent::CursorCli => "Cursor",
|
||||
CLIAgent::Unknown => "CLI Agent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Icon for this CLI agent, or `None` for unknown/custom agents.
|
||||
pub fn icon(&self) -> Option<Icon> {
|
||||
match self {
|
||||
CLIAgent::Claude => Some(Icon::ClaudeLogo),
|
||||
CLIAgent::Gemini => Some(Icon::GeminiLogo),
|
||||
CLIAgent::Codex => Some(Icon::OpenAILogo),
|
||||
CLIAgent::Amp => Some(Icon::AmpLogo),
|
||||
CLIAgent::Droid => Some(Icon::DroidLogo),
|
||||
CLIAgent::OpenCode => Some(Icon::OpenCodeLogo),
|
||||
CLIAgent::Copilot => Some(Icon::CopilotLogo),
|
||||
CLIAgent::Pi => Some(Icon::PiLogo),
|
||||
CLIAgent::Auggie => Some(Icon::AuggieLogo),
|
||||
CLIAgent::CursorCli => Some(Icon::CursorLogo),
|
||||
CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the skill providers whose skills this CLI agent can natively interpret.
|
||||
/// When the CLI agent rich input is open, only skills from these providers are shown
|
||||
/// in the slash menu. Returns an empty slice for agents with no known skills support.
|
||||
pub fn supported_skill_providers(&self) -> &'static [SkillProvider] {
|
||||
match self {
|
||||
CLIAgent::Claude => &[SkillProvider::Claude],
|
||||
CLIAgent::Codex => &[
|
||||
SkillProvider::Agents,
|
||||
SkillProvider::Claude,
|
||||
SkillProvider::Codex,
|
||||
],
|
||||
CLIAgent::OpenCode => &[
|
||||
SkillProvider::OpenCode,
|
||||
SkillProvider::Agents,
|
||||
SkillProvider::Claude,
|
||||
],
|
||||
CLIAgent::Gemini => &[SkillProvider::Agents, SkillProvider::Gemini],
|
||||
CLIAgent::Amp => &[SkillProvider::Agents],
|
||||
CLIAgent::Copilot => &[SkillProvider::Agents, SkillProvider::Copilot],
|
||||
CLIAgent::Droid => &[SkillProvider::Droid, SkillProvider::Agents],
|
||||
CLIAgent::Pi => &[SkillProvider::Agents],
|
||||
CLIAgent::Auggie => &[SkillProvider::Agents],
|
||||
CLIAgent::CursorCli => &[SkillProvider::Agents],
|
||||
CLIAgent::Unknown => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the prefix character used for skill invocations by this CLI agent.
|
||||
/// Most agents use `/` (e.g. `/skill-name`), but Codex uses `$` (e.g. `$skill-name`).
|
||||
pub fn skill_command_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
CLIAgent::Codex => "$",
|
||||
_ => "/",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this CLI agent supports the `!` bash mode prefix in the rich input.
|
||||
/// When `true`, typing `!` in the CLI agent rich input activates shell mode with
|
||||
/// decorations, completions, and error underlining.
|
||||
///
|
||||
/// TODO(advait): Check whether Gemini, Amp, Droid, and Copilot support `!` bash
|
||||
/// mode and enable them here if so.
|
||||
pub fn supports_bash_mode(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
CLIAgent::Claude | CLIAgent::Codex | CLIAgent::OpenCode
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the brand color for this CLI agent, or `None` for unknown/custom agents.
|
||||
pub fn brand_color(&self) -> Option<ColorU> {
|
||||
match self {
|
||||
CLIAgent::Claude => Some(CLAUDE_ORANGE),
|
||||
CLIAgent::Gemini => Some(GEMINI_BLUE),
|
||||
CLIAgent::Codex => Some(OPENAI_COLOR),
|
||||
CLIAgent::Amp => Some(AMP_COLOR),
|
||||
CLIAgent::Droid => Some(DROID_COLOR),
|
||||
CLIAgent::OpenCode => Some(OPENCODE_COLOR),
|
||||
CLIAgent::Copilot => Some(COPILOT_COLOR),
|
||||
CLIAgent::Pi => Some(PI_COLOR),
|
||||
CLIAgent::Auggie => Some(AUGGIE_COLOR),
|
||||
CLIAgent::CursorCli => Some(CURSOR_COLOR),
|
||||
CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the icon color to use when rendered on the brand-colored circle background.
|
||||
/// 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),
|
||||
_ => ColorU::white(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the first meaningful command token from a command string.
|
||||
///
|
||||
/// When `escape_char` is provided, uses shell parsing to skip leading
|
||||
/// env-var assignments (e.g. `FOO=1 claude` → `claude`).
|
||||
/// Otherwise falls back to a simple whitespace split.
|
||||
fn extract_first_command(command: &str, escape_char: Option<EscapeChar>) -> Option<String> {
|
||||
match escape_char {
|
||||
Some(esc) => top_level_command(command, esc),
|
||||
None => command.split_whitespace().next().map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the CLI agent from a command string.
|
||||
///
|
||||
/// When `escape_char` is provided, full shell parsing is used to skip leading
|
||||
/// env-var assignments (e.g. `FOO=1 claude`). Otherwise falls back to a simple
|
||||
/// whitespace split.
|
||||
///
|
||||
/// If `aliases` is provided, the first word of the command will be looked up
|
||||
/// in the alias map. If found, the alias value replaces the first word to
|
||||
/// produce the resolved command used for detection.
|
||||
///
|
||||
/// Returns `Some(CLIAgent)` if the command matches a known CLI agent, `None` otherwise.
|
||||
pub fn detect(
|
||||
command: &str,
|
||||
escape_char: Option<EscapeChar>,
|
||||
aliases: Option<&HashMap<SmolStr, String>>,
|
||||
ctx: &AppContext,
|
||||
) -> Option<CLIAgent> {
|
||||
let trimmed = command.trim_start();
|
||||
let first_word = Self::extract_first_command(trimmed, escape_char)?;
|
||||
|
||||
// Resolve the full command through aliases. If the first word matches an
|
||||
// alias, replace it with the alias value to produce the resolved command.
|
||||
let resolved_command: Cow<'_, str> = aliases
|
||||
.and_then(|a| a.get(first_word.as_str()))
|
||||
.map(|alias_value| {
|
||||
let rest = trimmed
|
||||
.find(first_word.as_str())
|
||||
.map(|pos| &trimmed[pos + first_word.len()..])
|
||||
.unwrap_or("");
|
||||
Cow::Owned(format!("{}{}", alias_value.trim(), rest))
|
||||
})
|
||||
.unwrap_or(Cow::Borrowed(trimmed));
|
||||
|
||||
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.
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if the resolved command is `aifx agent run claude` (Uber's
|
||||
/// internal wrapper around Claude) and the user is on the Uber team.
|
||||
/// We special-case this so Uber employees get the toolbar without needing
|
||||
/// to configure anything.
|
||||
fn is_aifx_agent_run_claude(resolved_command: &str, ctx: &AppContext) -> bool {
|
||||
resolved_command.starts_with("aifx agent run claude")
|
||||
&& Self::is_on_uber_team(UserWorkspaces::as_ref(ctx))
|
||||
}
|
||||
|
||||
fn is_on_uber_team(user_workspaces: &UserWorkspaces) -> bool {
|
||||
user_workspaces
|
||||
.workspaces()
|
||||
.iter()
|
||||
.flat_map(|workspace| workspace.teams.iter())
|
||||
.any(|team| team.uid.uid() == UBER_TEAM_UID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a prompt string from a batch of code review comments suitable for
|
||||
/// writing to a CLI agent's PTY.
|
||||
///
|
||||
/// # Location format
|
||||
/// Locations use `L<line>` notation (1-indexed).
|
||||
/// Line ranges are written `L<start>-L<end>` where both ends are **inclusive**.
|
||||
/// Instructs the agent to run `git diff` for deleted-line context rather than
|
||||
/// inlining the full diff.
|
||||
pub fn build_review_prompt(review: &AgentReviewCommentBatch) -> String {
|
||||
let mut text = String::from(
|
||||
"Please address the following code review comments. \
|
||||
Run `git diff` (or `git diff HEAD`) to see the full context of any changes, \
|
||||
especially for deleted lines.\n",
|
||||
);
|
||||
|
||||
for comment in &review.comments {
|
||||
if comment.outdated {
|
||||
continue;
|
||||
}
|
||||
let body = export_review_comment_for_cli_prompt(&comment.content);
|
||||
let location = match &comment.target {
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path,
|
||||
line,
|
||||
..
|
||||
} => {
|
||||
let path = absolute_file_path.display();
|
||||
match line {
|
||||
EditorLineLocation::Current { line_number, .. } => {
|
||||
let n = line_number.as_usize() + 1;
|
||||
format!("{path} L{n}")
|
||||
}
|
||||
EditorLineLocation::Removed { line_number, .. } => {
|
||||
let n = line_number.as_usize() + 1;
|
||||
format!("{path} (deleted, was L{n} — see `git diff`)")
|
||||
}
|
||||
EditorLineLocation::Collapsed { line_range } => {
|
||||
// line_range is [start, end) 0-indexed; convert to L<start>-L<end>
|
||||
// where both start and end are 1-indexed inclusive.
|
||||
let start = line_range.start.as_usize() + 1;
|
||||
let end = line_range.end.as_usize();
|
||||
format!("{path} (collapsed hunk, L{start}-L{end} — see `git diff`)")
|
||||
}
|
||||
}
|
||||
}
|
||||
AttachedReviewCommentTarget::File { absolute_file_path } => {
|
||||
let path = absolute_file_path.display();
|
||||
let abs_str = absolute_file_path.to_string_lossy();
|
||||
let is_deleted = review.diff_set.iter().any(|(file_key, hunks)| {
|
||||
abs_str.ends_with(file_key.as_str())
|
||||
&& !hunks.is_empty()
|
||||
&& hunks
|
||||
.iter()
|
||||
.all(|h| h.lines_added == 0 && h.lines_removed > 0)
|
||||
});
|
||||
if is_deleted {
|
||||
format!("{path} (deleted file — see `git diff`)")
|
||||
} else {
|
||||
format!("{path}")
|
||||
}
|
||||
}
|
||||
AttachedReviewCommentTarget::General => "General".to_string(),
|
||||
};
|
||||
text.push_str(&format!("\n- {location}: {body}"));
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn export_review_comment_for_cli_prompt(comment: &str) -> String {
|
||||
let mut result = parse_markdown(comment)
|
||||
.map(|parsed| {
|
||||
Buffer::export_to_markdown(
|
||||
parsed,
|
||||
None,
|
||||
MarkdownStyle::Export {
|
||||
app_context: None,
|
||||
should_not_escape_markdown_punctuation: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|_| comment.to_string());
|
||||
result.truncate(result.trim_end().len());
|
||||
result
|
||||
}
|
||||
|
||||
/// Builds a prompt string for a single diff hunk location suitable for writing
|
||||
/// to a CLI agent's PTY. Includes change stats (+N -N) and instructs the agent
|
||||
/// to run `git diff` for full context.
|
||||
///
|
||||
/// # Location format
|
||||
/// `<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,
|
||||
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}) \
|
||||
-- run `git diff` to see the full context."
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a prompt string for a set of diff file context hunks suitable for
|
||||
/// writing to a CLI agent's PTY.
|
||||
///
|
||||
/// # Location format
|
||||
/// Each line is `<path> L<start>-L<end> (+N -N)` where `start` and `end` are
|
||||
/// 1-indexed and both ends are **inclusive**.
|
||||
pub fn build_diff_context_prompt(file_diffs: &HashMap<String, Vec<DiffSetHunk>>) -> String {
|
||||
let mut text = String::new();
|
||||
let mut sorted_keys: Vec<&String> = file_diffs.keys().collect();
|
||||
sorted_keys.sort();
|
||||
for file_key in sorted_keys {
|
||||
let hunks = &file_diffs[file_key];
|
||||
for hunk in hunks {
|
||||
// hunk.line_range is [start, end) 0-indexed; convert to L<start>-L<end>
|
||||
// where both start and end are 1-indexed inclusive.
|
||||
let start = hunk.line_range.start.as_usize() + 1;
|
||||
let end = hunk.line_range.end.as_usize();
|
||||
text.push_str(&format!(
|
||||
"{file_key} L{start}-L{end} (+{} -{})",
|
||||
hunk.lines_added, hunk.lines_removed,
|
||||
));
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
// Remove trailing newline.
|
||||
text.truncate(text.trim_end().len());
|
||||
text
|
||||
}
|
||||
|
||||
/// Builds a prompt for a single-line text selection suitable for writing to a CLI agent's PTY.
|
||||
/// Prefixes the literal text with its file path and line number for context.
|
||||
///
|
||||
/// # Format
|
||||
/// `<path> L<line>: <text>` where `line` is 1-indexed.
|
||||
pub fn build_selection_substring_prompt(file_path: &str, line: usize, text: &str) -> String {
|
||||
format!("{file_path} L{line}: {text}")
|
||||
}
|
||||
|
||||
/// Builds a prompt for a multi-line selection suitable for writing to a CLI agent's PTY.
|
||||
/// For single-line selections, use [`build_selection_substring_prompt`] instead.
|
||||
///
|
||||
/// # Location format
|
||||
/// `<path> L<start>-L<end>` where line numbers are 1-indexed and both ends are inclusive.
|
||||
pub fn build_selection_line_range_prompt(
|
||||
file_path: &str,
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
) -> String {
|
||||
format!("{file_path} L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
impl From<CLIAgent> for CLIAgentType {
|
||||
fn from(agent: CLIAgent) -> Self {
|
||||
match agent {
|
||||
CLIAgent::Claude => CLIAgentType::Claude,
|
||||
CLIAgent::Gemini => CLIAgentType::Gemini,
|
||||
CLIAgent::Codex => CLIAgentType::Codex,
|
||||
CLIAgent::Amp => CLIAgentType::Amp,
|
||||
CLIAgent::Droid => CLIAgentType::Droid,
|
||||
CLIAgent::OpenCode => CLIAgentType::OpenCode,
|
||||
CLIAgent::Copilot => CLIAgentType::Copilot,
|
||||
CLIAgent::Pi => CLIAgentType::Pi,
|
||||
CLIAgent::Auggie => CLIAgentType::Auggie,
|
||||
CLIAgent::CursorCli => CLIAgentType::Cursor,
|
||||
CLIAgent::Unknown => CLIAgentType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cli_agent_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,95 @@
|
||||
mod v1;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
type EventParser = fn(&str) -> Option<CLIAgentEvent>;
|
||||
|
||||
/// Sentinel title that identifies structured CLI agent events sent via OSC 777.
|
||||
/// The `"agent"` field in the JSON body distinguishes which agent sent it.
|
||||
pub const CLI_AGENT_NOTIFICATION_SENTINEL: &str = "warp://cli-agent";
|
||||
|
||||
/// The event type encoded in the `"event"` field of the JSON body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CLIAgentEventType {
|
||||
SessionStart,
|
||||
PromptSubmit,
|
||||
ToolComplete,
|
||||
Stop,
|
||||
PermissionRequest,
|
||||
PermissionReplied,
|
||||
QuestionAsked,
|
||||
IdlePrompt,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Event-specific fields that vary by event type.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CLIAgentEventPayload {
|
||||
pub query: Option<String>,
|
||||
pub response: Option<String>,
|
||||
pub transcript_path: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub tool_name: Option<String>,
|
||||
pub tool_input_preview: Option<String>,
|
||||
pub plugin_version: Option<String>,
|
||||
}
|
||||
|
||||
/// A parsed event from a CLI agent plugin.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CLIAgentEvent {
|
||||
pub v: u32,
|
||||
pub agent: CLIAgent,
|
||||
pub event: CLIAgentEventType,
|
||||
pub session_id: Option<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub payload: CLIAgentEventPayload,
|
||||
}
|
||||
|
||||
/// Version-specific parsers, indexed by (version - 1).
|
||||
/// Adding a new version means appending a parser here,
|
||||
/// which automatically bumps `current_protocol_version()`.
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
const VERSIONED_PARSERS: &[EventParser] = &[v1::parse];
|
||||
|
||||
/// The current CLI agent protocol version this build of Warp supports.
|
||||
/// Exported as the `WARP_CLI_AGENT_PROTOCOL_VERSION` env var on the PTY
|
||||
/// so plugins can negotiate a compatible payload format.
|
||||
#[cfg_attr(not(feature = "local_tty"), allow(dead_code))]
|
||||
pub const fn current_protocol_version() -> u32 {
|
||||
VERSIONED_PARSERS.len() as u32
|
||||
}
|
||||
|
||||
/// Attempts to parse an OSC 777 `PluggableNotification` into a typed `CLIAgentEvent`.
|
||||
/// Dispatches to the correct version-specific parser based on the `"v"` field. Returns `None`
|
||||
/// if the title doesn't match the sentinel, the body isn't valid JSON, or the version is unsupported.
|
||||
pub fn parse_event(title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
|
||||
if title? != CLI_AGENT_NOTIFICATION_SENTINEL {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_probe: VersionProbe = serde_json::from_str(body).ok()?;
|
||||
let version = version_probe.v.unwrap_or(1);
|
||||
|
||||
let index = (version as usize).checked_sub(1)?;
|
||||
match VERSIONED_PARSERS.get(index) {
|
||||
Some(parser) => parser(body),
|
||||
None => {
|
||||
log::error!(
|
||||
"Received CLI agent event with unsupported schema version \
|
||||
{version}. The CLI agent plugin or Warp may need to be updated."
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VersionProbe {
|
||||
v: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
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> {
|
||||
enum_iterator::all::<CLIAgent>()
|
||||
.find(|a| !matches!(a, CLIAgent::Unknown) && a.command_prefix() == agent)
|
||||
}
|
||||
|
||||
pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
|
||||
let raw: RawEvent = serde_json::from_str(body).ok()?;
|
||||
|
||||
let event = match raw.event.as_str() {
|
||||
"session_start" => CLIAgentEventType::SessionStart,
|
||||
"prompt_submit" => CLIAgentEventType::PromptSubmit,
|
||||
"tool_complete" => CLIAgentEventType::ToolComplete,
|
||||
"stop" => CLIAgentEventType::Stop,
|
||||
"permission_request" => CLIAgentEventType::PermissionRequest,
|
||||
"permission_replied" => CLIAgentEventType::PermissionReplied,
|
||||
"question_asked" => CLIAgentEventType::QuestionAsked,
|
||||
"idle_prompt" => CLIAgentEventType::IdlePrompt,
|
||||
other => CLIAgentEventType::Unknown(other.to_string()),
|
||||
};
|
||||
|
||||
let tool_input_preview = raw.tool_input.as_ref().and_then(|val| {
|
||||
val.get("command")
|
||||
.or_else(|| val.get("file_path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let agent = raw
|
||||
.agent
|
||||
.as_deref()
|
||||
.and_then(resolve_agent)
|
||||
.unwrap_or(CLIAgent::Unknown);
|
||||
|
||||
Some(CLIAgentEvent {
|
||||
v: raw.v.unwrap_or(1),
|
||||
agent,
|
||||
event,
|
||||
session_id: raw.session_id,
|
||||
cwd: raw.cwd,
|
||||
project: raw.project,
|
||||
payload: CLIAgentEventPayload {
|
||||
query: raw.query,
|
||||
response: raw.response,
|
||||
transcript_path: raw.transcript_path,
|
||||
summary: raw.summary,
|
||||
tool_name: raw.tool_name,
|
||||
tool_input_preview,
|
||||
plugin_version: raw.plugin_version,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawEvent {
|
||||
v: Option<u32>,
|
||||
agent: Option<String>,
|
||||
event: String,
|
||||
session_id: Option<String>,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
query: Option<String>,
|
||||
response: Option<String>,
|
||||
transcript_path: Option<String>,
|
||||
summary: Option<String>,
|
||||
tool_name: Option<String>,
|
||||
tool_input: Option<serde_json::Value>,
|
||||
plugin_version: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use warpui::{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::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
/// Per-agent handler that filters and transforms parsed CLI agent events.
|
||||
/// Each CLI agent can have a different implementation depending on which events
|
||||
/// it cares about.
|
||||
trait CLIAgentSessionHandler {
|
||||
/// Attempt to parse a raw `PluggableNotification` into a typed event.
|
||||
/// 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> {
|
||||
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.
|
||||
pub fn is_agent_supported(agent: &CLIAgent) -> bool {
|
||||
matches!(
|
||||
agent,
|
||||
CLIAgent::Claude
|
||||
| CLIAgent::OpenCode
|
||||
| CLIAgent::Codex
|
||||
| CLIAgent::Gemini
|
||||
| CLIAgent::Auggie
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
// 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
|
||||
| CLIAgent::Droid
|
||||
| CLIAgent::Copilot
|
||||
| CLIAgent::Pi
|
||||
| CLIAgent::CursorCli
|
||||
| CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default handler shared by agents whose events need no special filtering
|
||||
/// beyond skipping the initial `SessionStart`.
|
||||
struct DefaultSessionListener;
|
||||
|
||||
impl CLIAgentSessionHandler for DefaultSessionListener {
|
||||
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
|
||||
// Skip session_start events (handled during listener construction)
|
||||
if event.event == CLIAgentEventType::SessionStart {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex-specific handler that parses plain-text OSC 9 desktop notifications
|
||||
/// into CLI agent 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.
|
||||
struct CodexSessionHandler;
|
||||
|
||||
impl CodexSessionHandler {
|
||||
/// Parse a plain-text OSC 9 notification body into a `CLIAgentEvent`.
|
||||
/// Returns `None` only for empty bodies.
|
||||
fn parse_osc9_text(body: &str) -> Option<CLIAgentEvent> {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Codex,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload {
|
||||
query: Some(body.to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// OSC 9 notifications have no title.
|
||||
if title.is_some() {
|
||||
return None;
|
||||
}
|
||||
Self::parse_osc9_text(body)
|
||||
}
|
||||
|
||||
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
|
||||
/// sessions model. Stored on [`super::CLIAgentSession`] so its lifetime is
|
||||
/// tied to the session; dropping the handle cleans up the subscription.
|
||||
pub struct CLIAgentSessionListener {
|
||||
terminal_view_id: EntityId,
|
||||
inner: Box<dyn CLIAgentSessionHandler>,
|
||||
}
|
||||
|
||||
impl warpui::Entity for CLIAgentSessionListener {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl CLIAgentSessionListener {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let handler =
|
||||
create_handler(&agent).expect("is_agent_supported must be checked before calling new");
|
||||
|
||||
// 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| {
|
||||
if let ModelEvent::PluggableNotification { title, body } = event {
|
||||
let Some(parsed) = me.inner.try_parse(title.as_deref(), body) 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
terminal_view_id,
|
||||
inner: handler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
pub mod event;
|
||||
pub mod listener;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod plugin_manager;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::InputConfig;
|
||||
|
||||
use self::listener::CLIAgentSessionListener;
|
||||
use super::CLIAgent;
|
||||
use event::{CLIAgentEvent, CLIAgentEventType};
|
||||
|
||||
/// Status of a tracked CLI agent session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CLIAgentSessionStatus {
|
||||
InProgress,
|
||||
Success,
|
||||
Blocked { message: Option<String> },
|
||||
}
|
||||
|
||||
impl CLIAgentSessionStatus {
|
||||
pub fn to_conversation_status(&self) -> crate::ai::agent::conversation::ConversationStatus {
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
match self {
|
||||
CLIAgentSessionStatus::InProgress => ConversationStatus::InProgress,
|
||||
CLIAgentSessionStatus::Success => ConversationStatus::Success,
|
||||
CLIAgentSessionStatus::Blocked { message } => ConversationStatus::Blocked {
|
||||
blocked_action: message.clone().unwrap_or_default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rich context accumulated from CLI agent session events.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CLIAgentSessionContext {
|
||||
pub cwd: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub tool_name: Option<String>,
|
||||
pub tool_input_preview: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub response: Option<String>,
|
||||
}
|
||||
|
||||
/// State of the rich input editor for composing a prompt to send to a CLI agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CLIAgentInputState {
|
||||
/// The rich input editor is not open.
|
||||
Closed,
|
||||
/// The rich input editor is open.
|
||||
Open {
|
||||
/// How this session was opened (for telemetry).
|
||||
entrypoint: CLIAgentInputEntrypoint,
|
||||
/// The input config that was active before opening rich input.
|
||||
previous_input_config: InputConfig,
|
||||
/// Whether the previous lock state was established while the input buffer was empty.
|
||||
previous_was_lock_set_with_empty_buffer: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why the CLI agent rich input was closed (for telemetry).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum CLIAgentRichInputCloseReason {
|
||||
/// User explicitly closed (Escape, Ctrl-G, footer button).
|
||||
Manual,
|
||||
/// Auto-closed due to agent status change (e.g. Blocked).
|
||||
AutoToggle,
|
||||
/// Auto-dismissed after submitting a prompt.
|
||||
Submit,
|
||||
/// Closed for another reason (chip removed, session ended, shared session sync).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// How a [`CLIAgentInputState`] was opened.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum CLIAgentInputEntrypoint {
|
||||
/// User pressed Ctrl-G while a CLI agent was active.
|
||||
CtrlG,
|
||||
/// User clicked the rich input button in the CLI agent footer.
|
||||
FooterButton,
|
||||
/// Automatically opened when the CLI agent resumed work (left a blocked state)
|
||||
/// and the auto-show setting is enabled.
|
||||
AutoShow,
|
||||
/// Rich input was opened to mirror a shared-session participant's state.
|
||||
SharedSessionSync,
|
||||
}
|
||||
|
||||
impl CLIAgentSessionContext {
|
||||
pub(crate) fn display_title(&self) -> Option<String> {
|
||||
self.latest_user_prompt().or_else(|| self.title_like_text())
|
||||
}
|
||||
|
||||
pub(crate) fn latest_user_prompt(&self) -> Option<String> {
|
||||
self.query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|query| !query.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Returns summary text suitable as a fallback title when no user prompt is available.
|
||||
pub(crate) fn title_like_text(&self) -> Option<String> {
|
||||
self.summary
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|summary| !summary.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked CLI agent session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CLIAgentSession {
|
||||
pub agent: CLIAgent,
|
||||
pub status: CLIAgentSessionStatus,
|
||||
pub session_context: CLIAgentSessionContext,
|
||||
/// Rich input editor state.
|
||||
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.
|
||||
/// 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.
|
||||
pub plugin_version: Option<String>,
|
||||
/// `None` when the session is local.
|
||||
/// `Some("user@hostname")` when running over SSH (warpified or legacy).
|
||||
/// Used as a key for per-host plugin install failure tracking.
|
||||
pub remote_host: Option<String>,
|
||||
/// Draft text saved from the rich input composer when it was closed.
|
||||
/// Restored into the editor when the composer is reopened.
|
||||
pub draft_text: Option<String>,
|
||||
/// When the session was detected via a custom toolbar command pattern,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl CLIAgentSession {
|
||||
pub fn is_remote(&self) -> bool {
|
||||
self.remote_host.is_some()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.session_context.cwd = event.cwd.clone().or(self.session_context.cwd.take());
|
||||
self.session_context.project = event
|
||||
.project
|
||||
.clone()
|
||||
.or(self.session_context.project.take());
|
||||
self.session_context.session_id = event
|
||||
.session_id
|
||||
.clone()
|
||||
.or(self.session_context.session_id.take());
|
||||
|
||||
let new_status = match &event.event {
|
||||
CLIAgentEventType::PromptSubmit => {
|
||||
self.session_context.query = event.payload.query.clone();
|
||||
self.session_context.response = None;
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
CLIAgentEventType::ToolComplete => {
|
||||
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
|
||||
return None;
|
||||
}
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
CLIAgentEventType::Stop => {
|
||||
self.session_context.query = event.payload.query.clone();
|
||||
self.session_context.response = event.payload.response.clone();
|
||||
CLIAgentSessionStatus::Success
|
||||
}
|
||||
CLIAgentEventType::PermissionRequest => {
|
||||
self.session_context.summary = event.payload.summary.clone();
|
||||
self.session_context.tool_name = event.payload.tool_name.clone();
|
||||
self.session_context.tool_input_preview = event.payload.tool_input_preview.clone();
|
||||
CLIAgentSessionStatus::Blocked {
|
||||
message: event.payload.summary.clone(),
|
||||
}
|
||||
}
|
||||
CLIAgentEventType::QuestionAsked => CLIAgentSessionStatus::Blocked {
|
||||
message: event
|
||||
.payload
|
||||
.summary
|
||||
.clone()
|
||||
.or_else(|| Some("Waiting for your answer".to_owned())),
|
||||
},
|
||||
CLIAgentEventType::PermissionReplied => {
|
||||
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
|
||||
return None;
|
||||
}
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
// IdlePrompt means the agent is sitting at its prompt waiting for input.
|
||||
// This should not affect status — otherwise it would override Success after a Stop event.
|
||||
CLIAgentEventType::IdlePrompt => return None,
|
||||
CLIAgentEventType::SessionStart => {
|
||||
self.plugin_version = event.payload.plugin_version.clone();
|
||||
return None;
|
||||
}
|
||||
CLIAgentEventType::Unknown(_) => return None,
|
||||
};
|
||||
|
||||
self.status = new_status.clone();
|
||||
Some(new_status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by `CLIAgentSessionsModel` for subscribers (e.g., `AgentNotificationsModel`).
|
||||
#[allow(dead_code)] // `agent` fields on Started/InputSessionChanged/Ended are used for logging and future subscribers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CLIAgentSessionsModelEvent {
|
||||
Started {
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
},
|
||||
StatusChanged {
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
status: CLIAgentSessionStatus,
|
||||
session_context: Box<CLIAgentSessionContext>,
|
||||
},
|
||||
InputSessionChanged {
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
/// The input state BEFORE this change. When transitioning from
|
||||
/// `Open` → `Closed`, contains the saved input config to restore.
|
||||
previous_input_state: CLIAgentInputState,
|
||||
/// The input state AFTER this change.
|
||||
new_input_state: CLIAgentInputState,
|
||||
},
|
||||
Ended {
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
},
|
||||
/// The agent session has been updated. Subscribers may use this as a trigger for best-effort
|
||||
/// saving of state derived from the agent's session.
|
||||
SessionUpdated {
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
},
|
||||
}
|
||||
|
||||
impl CLIAgentSessionsModelEvent {
|
||||
pub fn terminal_view_id(&self) -> EntityId {
|
||||
match self {
|
||||
CLIAgentSessionsModelEvent::Started {
|
||||
terminal_view_id, ..
|
||||
}
|
||||
| CLIAgentSessionsModelEvent::StatusChanged {
|
||||
terminal_view_id, ..
|
||||
}
|
||||
| CLIAgentSessionsModelEvent::InputSessionChanged {
|
||||
terminal_view_id, ..
|
||||
}
|
||||
| CLIAgentSessionsModelEvent::Ended {
|
||||
terminal_view_id, ..
|
||||
}
|
||||
| CLIAgentSessionsModelEvent::SessionUpdated {
|
||||
terminal_view_id, ..
|
||||
} => *terminal_view_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model that tracks pane-scoped CLI agent state and plugin-enriched session context.
|
||||
pub struct CLIAgentSessionsModel {
|
||||
sessions: HashMap<EntityId, CLIAgentSession>,
|
||||
/// Tracks (agent, remote_host) pairs where an auto plugin operation (install or update) has failed.
|
||||
/// Shared across all views so failure in one tab is reflected everywhere.
|
||||
plugin_auto_failures: HashSet<(CLIAgent, Option<String>)>,
|
||||
}
|
||||
|
||||
impl Entity for CLIAgentSessionsModel {
|
||||
type Event = CLIAgentSessionsModelEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for CLIAgentSessionsModel {}
|
||||
|
||||
impl CLIAgentSessionsModel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
plugin_auto_failures: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self, terminal_view_id: EntityId) -> Option<&CLIAgentSession> {
|
||||
self.sessions.get(&terminal_view_id)
|
||||
}
|
||||
|
||||
/// Returns `true` if the rich input editor is currently open for this terminal.
|
||||
pub fn is_input_open(&self, terminal_view_id: EntityId) -> bool {
|
||||
self.sessions
|
||||
.get(&terminal_view_id)
|
||||
.is_some_and(|s| matches!(s.input_state, CLIAgentInputState::Open { .. }))
|
||||
}
|
||||
|
||||
/// Registers a plugin-backed listener on the session for this terminal.
|
||||
///
|
||||
/// If a session for the same agent already exists (e.g. created earlier by
|
||||
/// command detection), it is upgraded with the listener and plugin context.
|
||||
/// Otherwise a new session is created.
|
||||
///
|
||||
/// The optional `cwd` / `project` / `session_id` fields supply initial
|
||||
/// context when available (e.g. from a `SessionStart` event). Passing
|
||||
/// `None` for all three is fine — happens when the plugin is installed
|
||||
/// mid-session and there is no start event to extract context from.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn register_listener(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
agent: CLIAgent,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
session_id: Option<String>,
|
||||
plugin_version: Option<String>,
|
||||
remote_host: Option<String>,
|
||||
should_auto_toggle_input: bool,
|
||||
listener: ModelHandle<CLIAgentSessionListener>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(session) = self
|
||||
.sessions
|
||||
.get_mut(&terminal_view_id)
|
||||
.filter(|s| s.agent == agent)
|
||||
{
|
||||
// Upgrade existing session with plugin context.
|
||||
session.status = CLIAgentSessionStatus::InProgress;
|
||||
session.listener = Some(listener);
|
||||
session.plugin_version = plugin_version;
|
||||
session.remote_host = remote_host;
|
||||
session.should_auto_toggle_input = should_auto_toggle_input;
|
||||
session.session_context.cwd = cwd.or(session.session_context.cwd.take());
|
||||
session.session_context.project = project.or(session.session_context.project.take());
|
||||
session.session_context.session_id =
|
||||
session_id.or(session.session_context.session_id.take());
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_session(
|
||||
terminal_view_id,
|
||||
CLIAgentSession {
|
||||
agent,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext {
|
||||
cwd,
|
||||
project,
|
||||
session_id,
|
||||
..Default::default()
|
||||
},
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input,
|
||||
listener: Some(listener),
|
||||
plugin_version,
|
||||
remote_host,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove_session(&mut self, terminal_view_id: EntityId, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(session) = self.sessions.remove(&terminal_view_id) {
|
||||
ctx.emit(CLIAgentSessionsModelEvent::Ended {
|
||||
terminal_view_id,
|
||||
agent: session.agent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the session's status and context from a parsed CLI agent event.
|
||||
pub fn update_from_event(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
event: &CLIAgentEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let event_type = &event.event;
|
||||
if let Some(new_status) = session.apply_event(event) {
|
||||
let agent = session.agent;
|
||||
ctx.emit(CLIAgentSessionsModelEvent::StatusChanged {
|
||||
terminal_view_id,
|
||||
agent,
|
||||
status: new_status,
|
||||
session_context: Box::new(session.session_context.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event_type,
|
||||
CLIAgentEventType::SessionStart
|
||||
| CLIAgentEventType::PromptSubmit
|
||||
| CLIAgentEventType::ToolComplete
|
||||
) {
|
||||
ctx.emit(CLIAgentSessionsModelEvent::SessionUpdated {
|
||||
terminal_view_id,
|
||||
agent: session.agent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_input(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
entrypoint: CLIAgentInputEntrypoint,
|
||||
previous_input_config: InputConfig,
|
||||
previous_was_lock_set_with_empty_buffer: bool,
|
||||
should_auto_toggle_input: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let previous_input_state = session.input_state;
|
||||
session.input_state = CLIAgentInputState::Open {
|
||||
entrypoint,
|
||||
previous_input_config,
|
||||
previous_was_lock_set_with_empty_buffer,
|
||||
};
|
||||
session.should_auto_toggle_input = should_auto_toggle_input;
|
||||
|
||||
ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged {
|
||||
terminal_view_id,
|
||||
agent: session.agent,
|
||||
previous_input_state,
|
||||
new_input_state: session.input_state,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn close_input(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
should_auto_toggle_input: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(session) = self.sessions.get_mut(&terminal_view_id) else {
|
||||
return;
|
||||
};
|
||||
if session.input_state == CLIAgentInputState::Closed {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_input_state = session.input_state;
|
||||
session.input_state = CLIAgentInputState::Closed;
|
||||
session.should_auto_toggle_input = should_auto_toggle_input;
|
||||
ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged {
|
||||
terminal_view_id,
|
||||
agent: session.agent,
|
||||
previous_input_state,
|
||||
new_input_state: CLIAgentInputState::Closed,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_session(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
session: CLIAgentSession,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let agent = session.agent;
|
||||
// Close any open rich input before replacing, so subscribers can
|
||||
// restore input config before the session ends.
|
||||
self.close_input(terminal_view_id, false, ctx);
|
||||
if let Some(old) = self.sessions.insert(terminal_view_id, session) {
|
||||
ctx.emit(CLIAgentSessionsModelEvent::Ended {
|
||||
terminal_view_id,
|
||||
agent: old.agent,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.emit(CLIAgentSessionsModelEvent::Started {
|
||||
terminal_view_id,
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
/// Records that an auto plugin operation (install or update) failed for the given agent/host.
|
||||
/// `remote_host` is `None` for local sessions, `Some("user@hostname")` for remote.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn record_plugin_auto_failure(&mut self, agent: CLIAgent, remote_host: Option<String>) {
|
||||
self.plugin_auto_failures.insert((agent, remote_host));
|
||||
}
|
||||
|
||||
/// Saves draft text from the rich input composer for the given terminal.
|
||||
/// Stores `None` for empty or whitespace-only text.
|
||||
pub fn set_draft(&mut self, terminal_view_id: EntityId, text: String) {
|
||||
if let Some(session) = self.sessions.get_mut(&terminal_view_id) {
|
||||
session.draft_text = if text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears any saved draft text for the given terminal.
|
||||
pub fn clear_draft(&mut self, terminal_view_id: EntityId) {
|
||||
if let Some(session) = self.sessions.get_mut(&terminal_view_id) {
|
||||
session.draft_text = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns and clears the draft text for the given terminal, if any.
|
||||
pub fn take_draft(&mut self, terminal_view_id: EntityId) -> Option<String> {
|
||||
self.sessions
|
||||
.get_mut(&terminal_view_id)
|
||||
.and_then(|s| s.draft_text.take())
|
||||
}
|
||||
|
||||
/// Whether an auto plugin operation has previously failed for this agent on this host.
|
||||
pub fn has_plugin_auto_failed(&self, agent: CLIAgent, remote_host: &Option<String>) -> bool {
|
||||
self.plugin_auto_failures
|
||||
.contains(&(agent, remote_host.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,410 @@
|
||||
use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
|
||||
use super::{
|
||||
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext,
|
||||
CLIAgentSessionStatus, CLIAgentSessionsModel,
|
||||
};
|
||||
use crate::ai::blocklist::{InputConfig, InputType};
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
#[test]
|
||||
fn parse_stop_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe","transcript_path":"/tmp/t.jsonl"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.v, 1);
|
||||
assert_eq!(notif.agent, CLIAgent::Claude);
|
||||
assert_eq!(notif.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(notif.session_id.as_deref(), Some("abc"));
|
||||
assert_eq!(notif.cwd.as_deref(), Some("/tmp/proj"));
|
||||
assert_eq!(notif.project.as_deref(), Some("proj"));
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
|
||||
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
|
||||
assert_eq!(
|
||||
notif.payload.transcript_path.as_deref(),
|
||||
Some("/tmp/t.jsonl")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_session_context_title_like_text_uses_trimmed_summary() {
|
||||
let context = CLIAgentSessionContext {
|
||||
summary: Some(" Reviewing changes ".to_string()),
|
||||
query: Some("Latest prompt".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
context.title_like_text(),
|
||||
Some("Reviewing changes".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_session_context_latest_user_prompt_uses_trimmed_query() {
|
||||
let context = CLIAgentSessionContext {
|
||||
summary: Some("Reviewing changes".to_string()),
|
||||
query: Some(" Latest prompt ".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
context.latest_user_prompt(),
|
||||
Some("Latest prompt".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_agent_session_context_title_helpers_ignore_empty_text() {
|
||||
let context = CLIAgentSessionContext {
|
||||
summary: Some(" ".to_string()),
|
||||
query: Some("".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(context.title_like_text(), None);
|
||||
assert_eq!(context.latest_user_prompt(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_permission_request_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp/proj","project":"proj","summary":"Wants to run Bash: rm -rf /tmp","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp"}}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.event, CLIAgentEventType::PermissionRequest);
|
||||
assert_eq!(
|
||||
notif.payload.summary.as_deref(),
|
||||
Some("Wants to run Bash: rm -rf /tmp")
|
||||
);
|
||||
assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash"));
|
||||
assert_eq!(
|
||||
notif.payload.tool_input_preview.as_deref(),
|
||||
Some("rm -rf /tmp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_permission_request_with_file_path() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp","project":"tmp","tool_name":"Write","tool_input":{"file_path":"/tmp/test.py","content":"print('hi')"}}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
notif.payload.tool_input_preview.as_deref(),
|
||||
Some("/tmp/test.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_idle_prompt_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"idle_prompt","session_id":"abc","cwd":"/tmp","project":"tmp","summary":"Claude is waiting for your input"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.event, CLIAgentEventType::IdlePrompt);
|
||||
assert_eq!(
|
||||
notif.payload.summary.as_deref(),
|
||||
Some("Claude is waiting for your input")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_start_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"session_start","session_id":"abc","cwd":"/tmp","project":"tmp","plugin_version":"1.1.0"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.event, CLIAgentEventType::SessionStart);
|
||||
assert_eq!(notif.payload.plugin_version.as_deref(), Some("1.1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_wrong_sentinel() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
|
||||
assert!(parse_event(Some("Claude Code"), body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_missing_title() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
|
||||
assert!(parse_event(None, body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_invalid_json() {
|
||||
assert!(parse_event(Some("warp://cli-agent"), "not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_unknown_event_type() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"some_future_event"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
assert_eq!(
|
||||
notif.event,
|
||||
CLIAgentEventType::Unknown("some_future_event".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_optional_fields() {
|
||||
let body = r#"{"event":"stop"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.v, 1);
|
||||
assert_eq!(notif.agent, CLIAgent::Unknown);
|
||||
assert_eq!(notif.event, CLIAgentEventType::Stop);
|
||||
assert!(notif.session_id.is_none());
|
||||
assert!(notif.cwd.is_none());
|
||||
assert!(notif.project.is_none());
|
||||
assert!(notif.payload.query.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_special_characters_in_values() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop","query":"what does \"hello\" mean?","response":"It means greeting. Use: printf(\"hello\")"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
notif.payload.query.as_deref(),
|
||||
Some("what does \"hello\" mean?")
|
||||
);
|
||||
assert_eq!(
|
||||
notif.payload.response.as_deref(),
|
||||
Some("It means greeting. Use: printf(\"hello\")")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_schema_version() {
|
||||
let body = r#"{"v":2,"agent":"claude","event":"stop"}"#;
|
||||
assert!(parse_event(Some("warp://cli-agent"), body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_v1_when_version_missing() {
|
||||
let body = r#"{"agent":"claude","event":"stop","query":"hi"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
assert_eq!(notif.v, 1);
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_v1_parses_correctly() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop","query":"test"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
assert_eq!(notif.v, 1);
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_submit_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"prompt_submit","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"fix the bug"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.event, CLIAgentEventType::PromptSubmit);
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("fix the bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_complete_notification() {
|
||||
let body = r#"{"v":1,"agent":"claude","event":"tool_complete","session_id":"abc","cwd":"/tmp/proj","project":"proj","tool_name":"Bash"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.event, CLIAgentEventType::ToolComplete);
|
||||
assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_auggie_stop_notification() {
|
||||
// Mirrors what the community auggie-warp plugin emits on the Stop hook.
|
||||
let body = r#"{"v":1,"agent":"auggie","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::Auggie);
|
||||
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 {
|
||||
entrypoint: CLIAgentInputEntrypoint::CtrlG,
|
||||
previous_input_config: InputConfig {
|
||||
input_type: InputType::Shell,
|
||||
is_locked: false,
|
||||
},
|
||||
previous_was_lock_set_with_empty_buffer: true,
|
||||
};
|
||||
let mut session = CLIAgentSession {
|
||||
agent: CLIAgent::Claude,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
remote_host: None,
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::PermissionRequest,
|
||||
session_id: Some("abc".to_string()),
|
||||
cwd: Some("/tmp/proj".to_string()),
|
||||
project: Some("proj".to_string()),
|
||||
payload: CLIAgentEventPayload {
|
||||
summary: Some("Needs approval".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(session.input_state, input_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_remote_returns_true_when_remote_host_is_set() {
|
||||
let 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: Some("user@devbox".to_owned()),
|
||||
custom_command_prefix: None,
|
||||
};
|
||||
assert!(session.is_remote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_remote_returns_false_when_remote_host_is_none() {
|
||||
let session = CLIAgentSession {
|
||||
agent: CLIAgent::Claude,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
remote_host: None,
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
};
|
||||
assert!(!session.is_remote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_failure_is_shared_across_local_sessions() {
|
||||
let mut model = CLIAgentSessionsModel::new();
|
||||
|
||||
model.record_plugin_auto_failure(CLIAgent::Claude, None);
|
||||
|
||||
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_failure_does_not_affect_remote_host() {
|
||||
let mut model = CLIAgentSessionsModel::new();
|
||||
|
||||
model.record_plugin_auto_failure(CLIAgent::Claude, None);
|
||||
|
||||
let remote = Some("user@devbox".to_owned());
|
||||
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &remote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_failure_does_not_affect_local() {
|
||||
let mut model = CLIAgentSessionsModel::new();
|
||||
|
||||
model.record_plugin_auto_failure(CLIAgent::Claude, Some("user@devbox".to_owned()));
|
||||
|
||||
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_failures_are_independent_per_host() {
|
||||
let mut model = CLIAgentSessionsModel::new();
|
||||
|
||||
let host_a = Some("user@host-a".to_owned());
|
||||
let host_b = Some("user@host-b".to_owned());
|
||||
|
||||
model.record_plugin_auto_failure(CLIAgent::Claude, host_a.clone());
|
||||
|
||||
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &host_a));
|
||||
assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &host_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_tracking_is_independent_per_agent() {
|
||||
let mut model = CLIAgentSessionsModel::new();
|
||||
|
||||
model.record_plugin_auto_failure(CLIAgent::Claude, None);
|
||||
|
||||
assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None));
|
||||
assert!(!model.has_plugin_auto_failed(CLIAgent::Gemini, &None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_start_sets_plugin_version() {
|
||||
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,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: Some("/tmp".to_owned()),
|
||||
project: Some("proj".to_owned()),
|
||||
payload: CLIAgentEventPayload {
|
||||
plugin_version: Some("1.5.0".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
assert_eq!(session.plugin_version.as_deref(), Some("1.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_start_without_plugin_version_leaves_none() {
|
||||
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,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
assert_eq!(session.plugin_version, None);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
|
||||
PluginInstructionStep, PluginInstructions,
|
||||
};
|
||||
use crate::terminal::model::session::LocalCommandExecutor;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
const PLUGIN_KEY: &str = "warp@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";
|
||||
|
||||
pub(super) struct ClaudeCodePluginManager {
|
||||
executor: LocalCommandExecutor,
|
||||
path_env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl ClaudeCodePluginManager {
|
||||
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("claude", args, &self.executor, env_vars, log).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CliAgentPluginManager for ClaudeCodePluginManager {
|
||||
fn minimum_plugin_version(&self) -> &'static str {
|
||||
MINIMUM_PLUGIN_VERSION
|
||||
}
|
||||
|
||||
fn can_auto_install(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_installed(&self) -> bool {
|
||||
let Ok(claude_dir) = claude_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
check_installed(&claude_dir)
|
||||
}
|
||||
|
||||
/// Runs `claude plugin` CLI commands via the session shell.
|
||||
async fn install(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
// Remove/re-add the marketplace to ensure the local clone is fresh, then
|
||||
// reinstall the plugin.
|
||||
// We use `plugin install` (not `plugin update`) because `marketplace
|
||||
// remove` unlinks the plugin, so `plugin update` would fail with
|
||||
// "Plugin is not installed".
|
||||
let _ = self
|
||||
.run_logged(
|
||||
&["plugin", "marketplace", "remove", MARKETPLACE_NAME],
|
||||
&mut log,
|
||||
)
|
||||
.await;
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
|
||||
// Sanity check: verify the on-disk version actually changed.
|
||||
let still_outdated = claude_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 run /reload-plugins to activate."
|
||||
}
|
||||
|
||||
fn update_success_message(&self) -> &'static str {
|
||||
"Warp plugin updated. Please run /reload-plugins to activate."
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
&INSTALL_INSTRUCTIONS
|
||||
}
|
||||
|
||||
fn update_instructions(&self) -> &'static PluginInstructions {
|
||||
&UPDATE_INSTRUCTIONS
|
||||
}
|
||||
|
||||
fn needs_update(&self) -> bool {
|
||||
let Ok(claude_dir) = claude_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
match installed_version(&claude_dir) {
|
||||
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
|
||||
// No version field means very old plugin.
|
||||
None => check_installed(&claude_dir),
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Install Warp Plugin for Claude Code",
|
||||
subtitle: "Ensure that jq is installed on your machine. Then, run these commands.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Add the Warp plugin marketplace repository",
|
||||
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Install the Warp plugin",
|
||||
command: "claude plugin install warp@claude-code-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &[
|
||||
"Restart Claude Code to activate the plugin.",
|
||||
"There are some known issues with Claude Code's plugin system. \
|
||||
If the plugin is not found after step 1, you can try manually adding an \"extraKnownMarketplaces\" entry to ~/.claude/settings.json.",
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
|
||||
title: "Update Warp Plugin for Claude Code",
|
||||
subtitle: "Run the following commands.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Remove the existing marketplace (if present)",
|
||||
command: "claude plugin marketplace remove claude-code-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Re-add the marketplace",
|
||||
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Install the latest plugin version",
|
||||
command: "claude plugin install warp@claude-code-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart Claude Code to activate the update."],
|
||||
});
|
||||
|
||||
fn check_installed(claude_dir: &Path) -> bool {
|
||||
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
|
||||
let Ok(contents) = fs::read_to_string(plugins_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(&contents) else {
|
||||
return false;
|
||||
};
|
||||
parsed
|
||||
.get("plugins")
|
||||
.and_then(|p| p.get(PLUGIN_KEY))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reads the installed version string for the Warp plugin, if present.
|
||||
fn installed_version(claude_dir: &Path) -> 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)?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("version")?
|
||||
.as_str()
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
|
||||
fn claude_home_dir() -> io::Result<PathBuf> {
|
||||
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
|
||||
return Ok(PathBuf::from(claude_home));
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".claude"))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not determine home directory",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "claude_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::fs;
|
||||
|
||||
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
|
||||
|
||||
#[test]
|
||||
fn installed_when_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_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_plugin_key_absent() {
|
||||
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": {
|
||||
"some-other-plugin": [{"version": "1.0.0"}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_plugin_array_empty() {
|
||||
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": []
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_file_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_json_invalid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
fs::write(plugins_dir.join("installed_plugins.json"), "not json").unwrap();
|
||||
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_plugins_key_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({"other_key": "value"});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
|
||||
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_installed_via_trait_with_claude_home_env() {
|
||||
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();
|
||||
|
||||
std::env::set_var("CLAUDE_HOME", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CLAUDE_HOME");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn not_installed_via_trait_when_claude_home_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_HOME", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CLAUDE_HOME");
|
||||
|
||||
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();
|
||||
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.5.0"}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_no_version_field() {
|
||||
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": [{"scope": "user"}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed_version(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_file_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(installed_version(dir.path()), None);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
|
||||
|
||||
pub(super) struct CodexPluginManager;
|
||||
|
||||
#[async_trait]
|
||||
impl CliAgentPluginManager for CodexPluginManager {
|
||||
fn minimum_plugin_version(&self) -> &'static str {
|
||||
"0.0.0"
|
||||
}
|
||||
|
||||
fn can_auto_install(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_update(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
&INSTALL_INSTRUCTIONS
|
||||
}
|
||||
|
||||
fn update_instructions(&self) -> &'static PluginInstructions {
|
||||
&EMPTY_INSTRUCTIONS
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
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 {
|
||||
title: "",
|
||||
subtitle: "",
|
||||
steps: &[],
|
||||
post_install_notes: &[],
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "codex_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,19 @@
|
||||
use super::CodexPluginManager;
|
||||
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
|
||||
|
||||
#[test]
|
||||
fn can_auto_install_is_false() {
|
||||
assert!(!CodexPluginManager.can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_support_update() {
|
||||
assert!(!CodexPluginManager.supports_update());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_instructions_has_steps() {
|
||||
let instructions = CodexPluginManager.install_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
|
||||
PluginInstructionStep, PluginInstructions,
|
||||
};
|
||||
use crate::terminal::model::session::LocalCommandExecutor;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
const EXTENSION_REPO: &str = "https://github.com/warpdotdev/gemini-cli-warp";
|
||||
const EXTENSION_NAME: &str = "gemini-warp";
|
||||
|
||||
// Keep in sync with the plugin version in warpdotdev/gemini-warp.
|
||||
const MINIMUM_PLUGIN_VERSION: &str = "1.0.0";
|
||||
|
||||
pub(super) struct GeminiPluginManager {
|
||||
executor: LocalCommandExecutor,
|
||||
path_env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl GeminiPluginManager {
|
||||
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("gemini", args, &self.executor, env_vars, log).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CliAgentPluginManager for GeminiPluginManager {
|
||||
fn minimum_plugin_version(&self) -> &'static str {
|
||||
MINIMUM_PLUGIN_VERSION
|
||||
}
|
||||
|
||||
fn can_auto_install(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_installed(&self) -> bool {
|
||||
let Ok(extensions_dir) = gemini_extensions_dir() else {
|
||||
return false;
|
||||
};
|
||||
check_installed(&extensions_dir)
|
||||
}
|
||||
|
||||
fn needs_update(&self) -> bool {
|
||||
let Ok(extensions_dir) = gemini_extensions_dir() else {
|
||||
return false;
|
||||
};
|
||||
match installed_version(&extensions_dir) {
|
||||
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
|
||||
// No version field means very old or malformed extension.
|
||||
None => check_installed(&extensions_dir),
|
||||
}
|
||||
}
|
||||
|
||||
async fn install(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(
|
||||
&["extensions", "install", EXTENSION_REPO, "--consent"],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(&["extensions", "update", EXTENSION_NAME], &mut log)
|
||||
.await?;
|
||||
|
||||
// Sanity check: verify the on-disk version actually changed.
|
||||
let still_outdated = gemini_extensions_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 Gemini CLI to activate."
|
||||
}
|
||||
|
||||
fn update_success_message(&self) -> &'static str {
|
||||
"Warp plugin updated. Please restart Gemini CLI to activate."
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
&INSTALL_INSTRUCTIONS
|
||||
}
|
||||
|
||||
fn update_instructions(&self) -> &'static PluginInstructions {
|
||||
&UPDATE_INSTRUCTIONS
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
|
||||
title: "Install Warp Plugin for Gemini CLI",
|
||||
subtitle: "Run the following command, then restart Gemini CLI.",
|
||||
steps: &[PluginInstructionStep {
|
||||
description: "Install the Warp extension",
|
||||
command:
|
||||
"gemini extensions install https://github.com/warpdotdev/gemini-cli-warp --consent",
|
||||
executable: true,
|
||||
link: None,
|
||||
}],
|
||||
post_install_notes: &["Restart Gemini CLI to activate the plugin."],
|
||||
});
|
||||
|
||||
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
|
||||
title: "Update Warp Plugin for Gemini CLI",
|
||||
subtitle: "Run the following command, then restart Gemini CLI.",
|
||||
steps: &[PluginInstructionStep {
|
||||
description: "Update the Warp extension",
|
||||
command: "gemini extensions update gemini-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
}],
|
||||
post_install_notes: &["Restart Gemini CLI to activate the update."],
|
||||
});
|
||||
|
||||
fn check_installed(extensions_dir: &Path) -> bool {
|
||||
let manifest_path = extensions_dir
|
||||
.join(EXTENSION_NAME)
|
||||
.join("gemini-extension.json");
|
||||
let Ok(contents) = fs::read_to_string(manifest_path) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::from_str::<Value>(&contents).is_ok()
|
||||
}
|
||||
|
||||
/// Reads the installed version string for the Warp extension, if present.
|
||||
fn installed_version(extensions_dir: &Path) -> Option<String> {
|
||||
let manifest_path = extensions_dir
|
||||
.join(EXTENSION_NAME)
|
||||
.join("gemini-extension.json");
|
||||
let contents = fs::read_to_string(manifest_path).ok()?;
|
||||
let parsed: Value = serde_json::from_str(&contents).ok()?;
|
||||
parsed.get("version")?.as_str().map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Returns the path to `~/.gemini/extensions`.
|
||||
fn gemini_extensions_dir() -> io::Result<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".gemini").join("extensions"))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not determine home directory",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gemini_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::fs;
|
||||
|
||||
use super::{
|
||||
check_installed, compare_versions, installed_version, CliAgentPluginManager,
|
||||
GeminiPluginManager, MINIMUM_PLUGIN_VERSION,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn can_auto_install_is_true() {
|
||||
assert!(GeminiPluginManager::new(None, None, None).can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_version() {
|
||||
assert_eq!(
|
||||
GeminiPluginManager::new(None, None, None).minimum_plugin_version(),
|
||||
"1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_instructions_has_steps() {
|
||||
let instructions = GeminiPluginManager::new(None, None, None).install_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_instructions_has_steps() {
|
||||
let instructions = GeminiPluginManager::new(None, None, None).update_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_when_extension_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"name": "warp",
|
||||
"version": "1.0.0",
|
||||
"description": "Warp terminal integration for Gemini CLI"
|
||||
});
|
||||
fs::write(
|
||||
ext_dir.join("gemini-extension.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_extension_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_json_invalid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
fs::write(ext_dir.join("gemini-extension.json"), "not json").unwrap();
|
||||
|
||||
assert!(!check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_version_when_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"name": "warp",
|
||||
"version": "1.5.0"
|
||||
});
|
||||
fs::write(
|
||||
ext_dir.join("gemini-extension.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_no_version_field() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"name": "warp"
|
||||
});
|
||||
fs::write(
|
||||
ext_dir.join("gemini-extension.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed_version(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_file_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(installed_version(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_logic_true_when_version_outdated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"name": "warp",
|
||||
"version": "0.9.0"
|
||||
});
|
||||
fs::write(
|
||||
ext_dir.join("gemini-extension.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let needs_update = match installed_version(dir.path()) {
|
||||
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
|
||||
None => check_installed(dir.path()),
|
||||
};
|
||||
assert!(needs_update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_logic_false_when_version_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ext_dir = dir.path().join("gemini-warp");
|
||||
fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"name": "warp",
|
||||
"version": "1.0.0"
|
||||
});
|
||||
fs::write(
|
||||
ext_dir.join("gemini-extension.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let needs_update = match installed_version(dir.path()) {
|
||||
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
|
||||
None => check_installed(dir.path()),
|
||||
};
|
||||
assert!(!needs_update);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
pub(crate) mod claude;
|
||||
pub(crate) mod codex;
|
||||
pub(crate) mod gemini;
|
||||
pub(crate) mod opencode;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
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)]
|
||||
pub enum PluginModalKind {
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// A single step in the plugin install/update instructions pane.
|
||||
pub(crate) struct PluginInstructionStep {
|
||||
pub description: &'static str,
|
||||
pub command: &'static str,
|
||||
/// When true, the code block shows a "Run" button that inserts the command into the terminal.
|
||||
/// Defaults-by-convention to `true`; set to `false` for steps that are not runnable
|
||||
/// (e.g. config file snippets).
|
||||
pub executable: bool,
|
||||
/// Optional URL rendered as a clickable "Learn more" link after the description.
|
||||
/// When set with an empty `command`, the code block is omitted entirely.
|
||||
pub link: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// All content needed to render the plugin instructions pane for a given agent.
|
||||
pub(crate) struct PluginInstructions {
|
||||
pub title: &'static str,
|
||||
pub subtitle: &'static str,
|
||||
pub steps: &'static [PluginInstructionStep],
|
||||
/// Displayed after the steps in the same style as the subtitle, one per paragraph.
|
||||
pub post_install_notes: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub(crate) struct PluginInstallError {
|
||||
/// Short description shown in the toast notification.
|
||||
pub message: String,
|
||||
/// Detailed log of every command/step that was attempted.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginInstallError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for PluginInstallError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
Self {
|
||||
message: msg.clone(),
|
||||
log: msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares two `X.Y.Z` version strings.
|
||||
/// Returns `Ordering::Less` if `a < b`, etc.
|
||||
/// Unparseable components are treated as 0.
|
||||
pub(crate) fn compare_versions(a: &str, b: &str) -> Ordering {
|
||||
let parse = |s: &str| -> [u64; 3] {
|
||||
let mut parts = s.splitn(3, '.');
|
||||
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
[major, minor, patch]
|
||||
};
|
||||
parse(a).cmp(&parse(b))
|
||||
}
|
||||
|
||||
/// Runs a CLI subcommand through [`LocalCommandExecutor`], appending the
|
||||
/// command and its output to `log`.
|
||||
pub(crate) async fn run_cli_command_logged(
|
||||
cli_name: &str,
|
||||
args: &[&str],
|
||||
executor: &LocalCommandExecutor,
|
||||
env_vars: Option<HashMap<String, String>>,
|
||||
log: &mut String,
|
||||
) -> Result<(), PluginInstallError> {
|
||||
let display_cmd = format!("{cli_name} {}", args.join(" "));
|
||||
log.push_str(&format!("$ {display_cmd}\n"));
|
||||
let result = executor
|
||||
.execute_local_command_in_login_shell(&display_cmd, None, env_vars)
|
||||
.await;
|
||||
match result {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
for stream in [&stdout, &stderr] {
|
||||
if stream.is_empty() {
|
||||
continue;
|
||||
}
|
||||
log.push_str(stream);
|
||||
if !stream.ends_with('\n') {
|
||||
log.push('\n');
|
||||
}
|
||||
}
|
||||
if output.success() {
|
||||
log.push('\n');
|
||||
return Ok(());
|
||||
}
|
||||
Err(PluginInstallError {
|
||||
message: format!("'{display_cmd}' failed"),
|
||||
log: log.to_owned(),
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
log.push_str(&format!("error: {err}\n"));
|
||||
Err(PluginInstallError {
|
||||
message: format!("failed to run '{display_cmd}'"),
|
||||
log: log.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the Warp notification plugin for a specific CLI agent.
|
||||
///
|
||||
/// Each supported CLI agent has its own implementation that knows how to
|
||||
/// check installation state and perform install/update operations.
|
||||
#[async_trait]
|
||||
pub(crate) trait CliAgentPluginManager: Send + Sync {
|
||||
/// The minimum plugin version required by this Warp build.
|
||||
fn minimum_plugin_version(&self) -> &'static str;
|
||||
|
||||
/// Whether this agent supports one-click auto-install/update.
|
||||
/// When `false`, the footer always opens the manual instructions modal.
|
||||
fn can_auto_install(&self) -> bool;
|
||||
|
||||
/// Whether the Warp notification plugin is installed.
|
||||
/// Default returns `false` (no filesystem check).
|
||||
fn is_installed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the on-disk plugin version is below the minimum required.
|
||||
/// Default returns `false` (no filesystem check).
|
||||
fn needs_update(&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> {
|
||||
Err(PluginInstallError {
|
||||
message: "Auto-install not supported for this agent".to_owned(),
|
||||
log: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the Warp notification plugin to the latest version.
|
||||
/// Default returns an error — only agents with `can_auto_install() == true` should override.
|
||||
async fn update(&self) -> Result<(), PluginInstallError> {
|
||||
Err(PluginInstallError {
|
||||
message: "Auto-update not supported for this agent".to_owned(),
|
||||
log: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Toast message shown after a successful auto-install.
|
||||
fn install_success_message(&self) -> &'static str {
|
||||
"Warp plugin installed. Please restart the session to activate."
|
||||
}
|
||||
|
||||
/// Toast message shown after a successful auto-update.
|
||||
fn update_success_message(&self) -> &'static str {
|
||||
"Warp plugin updated. Please restart the session to activate."
|
||||
}
|
||||
|
||||
/// Manual installation instructions for the modal UI.
|
||||
fn install_instructions(&self) -> &'static PluginInstructions;
|
||||
|
||||
/// Whether this agent supports version-based update checking.
|
||||
/// When `false`, the update chip is never shown; only the install chip appears.
|
||||
fn supports_update(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Manual update instructions for the modal UI.
|
||||
fn update_instructions(&self) -> &'static PluginInstructions;
|
||||
|
||||
/// Install the Oz platform plugin for this CLI agent, if one exists,
|
||||
/// which provides skills that third-party harnesses can use to interact with
|
||||
/// the Oz platform.
|
||||
/// Default is a no-op — only agents with a platform plugin should override.
|
||||
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
|
||||
/// doesn't have Warp notification plugin support.
|
||||
pub(crate) fn plugin_manager_for(agent: CLIAgent) -> Option<Box<dyn CliAgentPluginManager>> {
|
||||
plugin_manager_for_with_shell(agent, None, None, None)
|
||||
}
|
||||
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
|
||||
/// doesn't have Warp notification plugin support.
|
||||
///
|
||||
/// When a shell path and type are provided, plugin commands run through that shell.
|
||||
/// When `path_env_var` is provided, it is set as the PATH for plugin commands
|
||||
/// (needed for nvm-installed tools that are only on PATH in interactive shells).
|
||||
pub(crate) fn plugin_manager_for_with_shell(
|
||||
agent: CLIAgent,
|
||||
shell_path: Option<PathBuf>,
|
||||
shell_type: Option<ShellType>,
|
||||
path_env_var: Option<String>,
|
||||
) -> Option<Box<dyn CliAgentPluginManager>> {
|
||||
match agent {
|
||||
CLIAgent::Claude => Some(Box::new(ClaudeCodePluginManager::new(
|
||||
shell_path,
|
||||
shell_type,
|
||||
path_env_var,
|
||||
))),
|
||||
CLIAgent::OpenCode
|
||||
if FeatureFlag::OpenCodeNotifications.is_enabled()
|
||||
&& FeatureFlag::HOANotifications.is_enabled() =>
|
||||
{
|
||||
Some(Box::new(OpenCodePluginManager))
|
||||
}
|
||||
CLIAgent::Codex
|
||||
if FeatureFlag::CodexNotifications.is_enabled()
|
||||
&& FeatureFlag::HOANotifications.is_enabled() =>
|
||||
{
|
||||
Some(Box::new(CodexPluginManager))
|
||||
}
|
||||
CLIAgent::Gemini
|
||||
if FeatureFlag::GeminiNotifications.is_enabled()
|
||||
&& FeatureFlag::HOANotifications.is_enabled() =>
|
||||
{
|
||||
Some(Box::new(GeminiPluginManager::new(
|
||||
shell_path,
|
||||
shell_type,
|
||||
path_env_var,
|
||||
)))
|
||||
}
|
||||
CLIAgent::OpenCode
|
||||
| CLIAgent::Codex
|
||||
| CLIAgent::Gemini
|
||||
| CLIAgent::Amp
|
||||
| CLIAgent::Droid
|
||||
| CLIAgent::Copilot
|
||||
| CLIAgent::Pi
|
||||
| CLIAgent::Auggie
|
||||
| CLIAgent::CursorCli
|
||||
| CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::{compare_versions, plugin_manager_for};
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
#[test]
|
||||
fn returns_manager_for_claude() {
|
||||
assert!(plugin_manager_for(CLIAgent::Claude).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_manager_for_opencode() {
|
||||
let _oc_guard = crate::features::FeatureFlag::OpenCodeNotifications.override_enabled(true);
|
||||
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
|
||||
assert!(plugin_manager_for(CLIAgent::OpenCode).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_manager_for_codex() {
|
||||
let _codex_guard = crate::features::FeatureFlag::CodexNotifications.override_enabled(true);
|
||||
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
|
||||
assert!(plugin_manager_for(CLIAgent::Codex).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_manager_for_gemini() {
|
||||
let _gemini_guard = crate::features::FeatureFlag::GeminiNotifications.override_enabled(true);
|
||||
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
|
||||
assert!(plugin_manager_for(CLIAgent::Gemini).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unsupported_agents() {
|
||||
assert!(plugin_manager_for(CLIAgent::Amp).is_none());
|
||||
assert!(plugin_manager_for(CLIAgent::Droid).is_none());
|
||||
assert!(plugin_manager_for(CLIAgent::Copilot).is_none());
|
||||
assert!(plugin_manager_for(CLIAgent::Unknown).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_equal() {
|
||||
assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_less_than_major() {
|
||||
assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_less_than_minor() {
|
||||
assert_eq!(compare_versions("1.1.0", "1.2.0"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_less_than_patch() {
|
||||
assert_eq!(compare_versions("1.1.0", "1.1.1"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_greater_than() {
|
||||
assert_eq!(compare_versions("3.0.0", "2.0.0"), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_unparseable_treated_as_zero() {
|
||||
assert_eq!(compare_versions("abc", "0.0.0"), Ordering::Equal);
|
||||
assert_eq!(compare_versions("abc", "1.0.0"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_partial_version_string() {
|
||||
assert_eq!(compare_versions("2", "2.0.0"), Ordering::Equal);
|
||||
assert_eq!(compare_versions("2.1", "2.1.0"), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_versions_empty_string() {
|
||||
assert_eq!(compare_versions("", "2.0.0"), Ordering::Less);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
|
||||
|
||||
// Keep in sync with the opencode-warp npm package version.
|
||||
// This version is also hardcoded into UPDATE_INSTRUCTIONS below (so the update
|
||||
// instructions tell users to pin to this specific version to force OpenCode's
|
||||
// plugin cache to re-fetch). Update both together.
|
||||
const MINIMUM_PLUGIN_VERSION: &str = "0.1.5";
|
||||
|
||||
pub(super) struct OpenCodePluginManager;
|
||||
|
||||
#[async_trait]
|
||||
impl CliAgentPluginManager for OpenCodePluginManager {
|
||||
fn minimum_plugin_version(&self) -> &'static str {
|
||||
MINIMUM_PLUGIN_VERSION
|
||||
}
|
||||
|
||||
fn can_auto_install(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
&INSTALL_INSTRUCTIONS
|
||||
}
|
||||
|
||||
fn update_instructions(&self) -> &'static PluginInstructions {
|
||||
&UPDATE_INSTRUCTIONS
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Install Warp Plugin for OpenCode",
|
||||
subtitle:
|
||||
"Add the Warp plugin to your OpenCode configuration, then restart OpenCode.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
|
||||
command: "~/.config/opencode/opencode.json",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Add \"@warp-dot-dev/opencode-warp\" to the \"plugin\" array in the top-level JSON object:",
|
||||
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp\"]",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart OpenCode to activate the plugin."],
|
||||
}
|
||||
});
|
||||
|
||||
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Update Warp Plugin for OpenCode",
|
||||
subtitle: "Pin the plugin to the latest version in your opencode.json. OpenCode caches plugins per version spec, so changing the pin forces it to re-fetch on restart.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
|
||||
command: "~/.config/opencode/opencode.json",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Replace the existing \"@warp-dot-dev/opencode-warp\" entry in the \"plugin\" array with the explicit version:",
|
||||
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp@0.1.5\"]",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart OpenCode to load the updated plugin."],
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "opencode_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,21 @@
|
||||
use super::OpenCodePluginManager;
|
||||
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
|
||||
|
||||
#[test]
|
||||
fn can_auto_install_is_false() {
|
||||
assert!(!OpenCodePluginManager.can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_instructions_has_steps() {
|
||||
let instructions = OpenCodePluginManager.install_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_instructions_has_steps() {
|
||||
let instructions = OpenCodePluginManager.update_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Local;
|
||||
use smol_str::SmolStr;
|
||||
use warp_editor::render::model::LineCount;
|
||||
use warp_util::path::EscapeChar;
|
||||
use warpui::App;
|
||||
|
||||
use super::{
|
||||
build_diff_hunk_prompt, build_review_prompt, build_selection_line_range_prompt,
|
||||
build_selection_substring_prompt, CLIAgent, UBER_TEAM_UID,
|
||||
};
|
||||
use crate::ai::agent::{AgentReviewCommentBatch, DiffSetHunk};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentOrigin, LineDiffContent,
|
||||
};
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::workspaces::team::Team;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::Workspace;
|
||||
|
||||
/// Helper to build an alias map from pairs.
|
||||
fn aliases(pairs: &[(&str, &str)]) -> HashMap<SmolStr, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (SmolStr::new(k), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for prompt-building tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_comment(
|
||||
content: &str,
|
||||
target: AttachedReviewCommentTarget,
|
||||
outdated: bool,
|
||||
) -> AttachedReviewComment {
|
||||
AttachedReviewComment {
|
||||
id: Default::default(),
|
||||
content: content.to_string(),
|
||||
target,
|
||||
last_update_time: Local::now(),
|
||||
base: None,
|
||||
head: None,
|
||||
outdated,
|
||||
origin: CommentOrigin::Native,
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(comments: Vec<AttachedReviewComment>) -> AgentReviewCommentBatch {
|
||||
AgentReviewCommentBatch {
|
||||
comments,
|
||||
diff_set: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_review_prompt tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_build_review_prompt_current_line_is_1_indexed() {
|
||||
// LineCount 0 (0-indexed) should appear as L1 in the prompt.
|
||||
let comment = make_comment(
|
||||
"fix this",
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: PathBuf::from("/repo/src/main.rs"),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: LineCount::from(0),
|
||||
line_range: LineCount::from(0)..LineCount::from(1),
|
||||
},
|
||||
content: LineDiffContent::default(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
assert!(
|
||||
prompt.contains("/repo/src/main.rs L1"),
|
||||
"expected 1-indexed L1, got: {prompt}",
|
||||
);
|
||||
assert!(prompt.contains("fix this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"),
|
||||
line: EditorLineLocation::Removed {
|
||||
line_number: LineCount::from(9),
|
||||
line_range: LineCount::from(9)..LineCount::from(10),
|
||||
index: 0,
|
||||
},
|
||||
content: LineDiffContent::default(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
assert!(
|
||||
prompt.contains("(deleted, was L10"),
|
||||
"expected 1-indexed L10, got: {prompt}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"),
|
||||
line: EditorLineLocation::Collapsed {
|
||||
line_range: LineCount::from(4)..LineCount::from(10),
|
||||
},
|
||||
content: LineDiffContent::default(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
// line_range is [4, 10) 0-indexed -> L5-L10 (1-indexed, both ends inclusive)
|
||||
assert!(prompt.contains("L5-L10"), "expected L5-L10, got: {prompt}",);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
assert!(prompt.contains("/repo/src/utils.rs: needs refactoring"));
|
||||
// Not a deleted file (empty diff_set), so no "deleted file" text.
|
||||
assert!(!prompt.contains("deleted file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut review = batch(vec![comment]);
|
||||
review.diff_set.insert(
|
||||
"src/old.rs".to_string(),
|
||||
vec![DiffSetHunk {
|
||||
line_range: LineCount::from(0)..LineCount::from(5),
|
||||
diff_content: String::new(),
|
||||
lines_added: 0,
|
||||
lines_removed: 5,
|
||||
}],
|
||||
);
|
||||
let prompt = build_review_prompt(&review);
|
||||
assert!(
|
||||
prompt.contains("(deleted file"),
|
||||
"expected deleted file annotation, got: {prompt}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_review_prompt_general_comment() {
|
||||
let comment = make_comment(
|
||||
"overall looks good",
|
||||
AttachedReviewCommentTarget::General,
|
||||
false,
|
||||
);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
assert!(prompt.contains("General: overall looks good"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_review_prompt_skips_outdated_comments() {
|
||||
let active = make_comment("keep me", AttachedReviewCommentTarget::General, false);
|
||||
let outdated = make_comment("skip me", AttachedReviewCommentTarget::General, true);
|
||||
let prompt = build_review_prompt(&batch(vec![active, outdated]));
|
||||
assert!(prompt.contains("keep me"));
|
||||
assert!(!prompt.contains("skip me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_review_prompt_multiple_comments() {
|
||||
let c1 = make_comment(
|
||||
"first",
|
||||
AttachedReviewCommentTarget::Line {
|
||||
absolute_file_path: PathBuf::from("/repo/a.rs"),
|
||||
line: EditorLineLocation::Current {
|
||||
line_number: LineCount::from(4),
|
||||
line_range: LineCount::from(4)..LineCount::from(5),
|
||||
},
|
||||
content: LineDiffContent::default(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let c2 = make_comment("second", AttachedReviewCommentTarget::General, false);
|
||||
let prompt = build_review_prompt(&batch(vec![c1, c2]));
|
||||
assert!(prompt.contains("/repo/a.rs L5: first"));
|
||||
assert!(prompt.contains("General: second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_review_prompt_exports_internal_markdown_without_punctuation_escapes() {
|
||||
let comment = make_comment("Fix this\\.", AttachedReviewCommentTarget::General, false);
|
||||
let prompt = build_review_prompt(&batch(vec![comment]));
|
||||
assert!(prompt.contains("General: Fix this."));
|
||||
assert!(!prompt.contains("Fix this\\."));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_diff_hunk_prompt tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_build_diff_hunk_prompt_format() {
|
||||
let prompt = build_diff_hunk_prompt(Path::new("/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.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_selection_line_range_prompt tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_build_selection_line_range_prompt_format() {
|
||||
let result = build_selection_line_range_prompt("src/foo.rs", 5, 10);
|
||||
assert_eq!(result, "src/foo.rs L5-L10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_selection_substring_prompt_format() {
|
||||
let result = build_selection_substring_prompt("src/foo.rs", 5, "let x = 42;");
|
||||
assert_eq!(result, "src/foo.rs L5: let x = 42;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_known_agents() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
for (command, expected) in [
|
||||
("claude", CLIAgent::Claude),
|
||||
("gemini", CLIAgent::Gemini),
|
||||
("codex", CLIAgent::Codex),
|
||||
("amp", CLIAgent::Amp),
|
||||
("droid", CLIAgent::Droid),
|
||||
("opencode", CLIAgent::OpenCode),
|
||||
("copilot", CLIAgent::Copilot),
|
||||
("agent", CLIAgent::CursorCli),
|
||||
] {
|
||||
assert_eq!(
|
||||
CLIAgent::detect(command, None, None, ctx),
|
||||
Some(expected),
|
||||
"failed to detect {command}",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_arguments() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect("claude --model opus", None, None, ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("gemini chat", None, None, ctx),
|
||||
Some(CLIAgent::Gemini),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_leading_whitespace() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect(" claude", None, None, ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("\tclaude --help", None, None, ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_no_match() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
assert_eq!(CLIAgent::detect("ls -la", None, None, ctx), None);
|
||||
assert_eq!(CLIAgent::detect("vim", None, None, ctx), None);
|
||||
assert_eq!(CLIAgent::detect("claude_wrapper", None, None, ctx), None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_alias() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
let map = aliases(&[("c", "claude")]);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("c", None, Some(&map), ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("c --help", None, Some(&map), ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_alias_not_matching() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
let map = aliases(&[("c", "cat")]);
|
||||
assert_eq!(CLIAgent::detect("c", None, Some(&map), ctx), None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_alias_multi_word_value() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
// Alias whose value starts with "gemini" but has extra words
|
||||
let map = aliases(&[("g", "gemini chat --verbose")]);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("g", None, Some(&map), ctx),
|
||||
Some(CLIAgent::Gemini),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_env_var_prefix() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect(
|
||||
"EXAMPLE=true opencode",
|
||||
Some(EscapeChar::Backslash),
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
Some(CLIAgent::OpenCode),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_multiple_env_vars() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect(
|
||||
"FOO=1 BAR=2 opencode --flag",
|
||||
Some(EscapeChar::Backslash),
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
Some(CLIAgent::OpenCode),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_with_alias_and_env_var() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
let map = aliases(&[("oc", "EXAMPLE=1 opencode")]);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("oc --flag", Some(EscapeChar::Backslash), Some(&map), ctx,),
|
||||
Some(CLIAgent::OpenCode),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a workspace containing a team with the given UID.
|
||||
fn workspace_with_team_uid(uid: &str) -> Workspace {
|
||||
Workspace::from_local_cache(
|
||||
ServerId::from_string_lossy("test-workspace-uid-001").into(),
|
||||
"Test Workspace".to_string(),
|
||||
Some(vec![Team::from_local_cache(
|
||||
ServerId::from_string_lossy(uid),
|
||||
"Test Team".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_aifx_agent_run_claude_on_uber_team() {
|
||||
App::test((), |mut app| async move {
|
||||
let uber_workspace = workspace_with_team_uid(UBER_TEAM_UID);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
vec![uber_workspace],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect("aifx agent run claude", None, None, ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
// With extra args
|
||||
assert_eq!(
|
||||
CLIAgent::detect("aifx agent run claude --verbose", None, None, ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_aifx_agent_run_claude_via_alias_on_uber_team() {
|
||||
App::test((), |mut app| async move {
|
||||
let uber_workspace = workspace_with_team_uid(UBER_TEAM_UID);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
vec![uber_workspace],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
let map = aliases(&[("ai", "aifx agent run claude")]);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("ai", None, Some(&map), ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect("ai --flag", None, Some(&map), ctx),
|
||||
Some(CLIAgent::Claude),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_aifx_agent_run_claude_not_on_uber_team() {
|
||||
App::test((), |mut app| async move {
|
||||
// Register UserWorkspaces with no Uber team membership
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect("aifx agent run claude", None, None, ctx),
|
||||
None,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialized_name_round_trips_known_agents() {
|
||||
for agent in enum_iterator::all::<CLIAgent>() {
|
||||
let name = agent.to_serialized_name();
|
||||
if agent == CLIAgent::Unknown {
|
||||
assert_eq!(name, "Unknown");
|
||||
} else {
|
||||
assert!(!name.is_empty(), "empty serialized name for {agent:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
CLIAgent::from_serialized_name(&name),
|
||||
agent,
|
||||
"round-trip failed for {agent:?} with serialized name {name:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_serialized_name_falls_back_to_unknown() {
|
||||
assert_eq!(CLIAgent::from_serialized_name(""), CLIAgent::Unknown);
|
||||
assert_eq!(
|
||||
CLIAgent::from_serialized_name("nonexistent"),
|
||||
CLIAgent::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_aifx_agent_run_claude_wrong_team() {
|
||||
App::test((), |mut app| async move {
|
||||
let other_workspace = workspace_with_team_uid("some-other-team-uid-01");
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
vec![other_workspace],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
assert_eq!(
|
||||
CLIAgent::detect("aifx agent run claude", None, None, ctx),
|
||||
None,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
use crate::terminal::model::ansi::color_index;
|
||||
use crate::themes::theme::{AnsiColors, WarpTheme};
|
||||
use std::fmt;
|
||||
use std::ops::{Index, IndexMut};
|
||||
use warpui::color::ColorU;
|
||||
|
||||
pub const COUNT: usize = 269;
|
||||
|
||||
/// Factor for automatic computation of dim colors used by terminal.
|
||||
const DIM_FACTOR: f32 = 0.66;
|
||||
|
||||
// TODO(alokedesai): we should move this into the terminal theme.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Colors {
|
||||
pub primary: PrimaryColors,
|
||||
pub normal: NormalColors,
|
||||
pub bright: BrightColors,
|
||||
pub dim: Option<DimColors>,
|
||||
pub indexed_colors: Vec<IndexedColor>,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
pub fn with_foreground_background_color(foreground: ColorU, background: ColorU) -> Self {
|
||||
Colors {
|
||||
primary: PrimaryColors {
|
||||
foreground,
|
||||
background,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(primary: PrimaryColors, normal: NormalColors, bright: BrightColors) -> Self {
|
||||
Colors {
|
||||
primary,
|
||||
normal,
|
||||
bright,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WarpTheme> for Colors {
|
||||
fn from(theme: WarpTheme) -> Self {
|
||||
let colors = theme.terminal_colors();
|
||||
Colors::new(
|
||||
PrimaryColors::new(
|
||||
theme.foreground().into_solid(),
|
||||
theme.background().into_solid(),
|
||||
),
|
||||
colors.normal.into(),
|
||||
colors.bright.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// List of indexed colors.
|
||||
///
|
||||
/// The first 16 entries are the standard ansi named colors. Items 16..232 are
|
||||
/// the color cube. Items 233..256 are the grayscale ramp. Item 256 is
|
||||
/// the configured foreground color, item 257 is the configured background
|
||||
/// color, item 258 is the cursor color. Following that are 8 positions for dim colors.
|
||||
/// Item 267 is the bright foreground color, 268 the dim foreground.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct List([ColorU; COUNT]);
|
||||
|
||||
/// Any overrided colors that may be set via escape sequence.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct OverrideList([Option<ColorU>; COUNT]);
|
||||
|
||||
impl OverrideList {
|
||||
pub fn empty() -> Self {
|
||||
OverrideList([None; COUNT])
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Colors> for List {
|
||||
fn from(colors: &Colors) -> List {
|
||||
// Type inference fails without this annotation.
|
||||
let mut list = List([ColorU::black(); COUNT]);
|
||||
|
||||
list.fill_named(colors);
|
||||
list.fill_cube(colors);
|
||||
list.fill_gray_ramp(colors);
|
||||
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
impl List {
|
||||
pub fn fill_named(&mut self, colors: &Colors) {
|
||||
// Normals.
|
||||
self[color_index::BLACK] = colors.normal.black;
|
||||
self[color_index::RED] = colors.normal.red;
|
||||
self[color_index::GREEN] = colors.normal.green;
|
||||
self[color_index::YELLOW] = colors.normal.yellow;
|
||||
self[color_index::BLUE] = colors.normal.blue;
|
||||
self[color_index::MAGENTA] = colors.normal.magenta;
|
||||
self[color_index::CYAN] = colors.normal.cyan;
|
||||
self[color_index::WHITE] = colors.normal.white;
|
||||
|
||||
// Brights.
|
||||
self[color_index::BRIGHT_BLACK] = colors.bright.black;
|
||||
self[color_index::BRIGHT_RED] = colors.bright.red;
|
||||
self[color_index::BRIGHT_GREEN] = colors.bright.green;
|
||||
self[color_index::BRIGHT_YELLOW] = colors.bright.yellow;
|
||||
self[color_index::BRIGHT_BLUE] = colors.bright.blue;
|
||||
self[color_index::BRIGHT_MAGENTA] = colors.bright.magenta;
|
||||
self[color_index::BRIGHT_CYAN] = colors.bright.cyan;
|
||||
self[color_index::BRIGHT_WHITE] = colors.bright.white;
|
||||
self[color_index::BRIGHT_FOREGROUND] = colors
|
||||
.primary
|
||||
.bright_foreground
|
||||
.unwrap_or(colors.primary.foreground);
|
||||
|
||||
// Foreground and background.
|
||||
self[color_index::FOREGROUND] = colors.primary.foreground;
|
||||
self[color_index::BACKGROUND] = colors.primary.background;
|
||||
|
||||
// Dims.
|
||||
self[color_index::DIM_FOREGROUND] = colors
|
||||
.primary
|
||||
.dim_foreground
|
||||
.unwrap_or_else(|| dim(colors.primary.foreground));
|
||||
match colors.dim {
|
||||
Some(ref dim) => {
|
||||
self[color_index::DIM_BLACK] = dim.black;
|
||||
self[color_index::DIM_RED] = dim.red;
|
||||
self[color_index::DIM_GREEN] = dim.green;
|
||||
self[color_index::DIM_YELLOW] = dim.yellow;
|
||||
self[color_index::DIM_BLUE] = dim.blue;
|
||||
self[color_index::DIM_MAGENTA] = dim.magenta;
|
||||
self[color_index::DIM_CYAN] = dim.cyan;
|
||||
self[color_index::DIM_WHITE] = dim.white;
|
||||
}
|
||||
None => {
|
||||
self[color_index::DIM_BLACK] = dim(colors.normal.black);
|
||||
self[color_index::DIM_RED] = dim(colors.normal.red);
|
||||
self[color_index::DIM_GREEN] = dim(colors.normal.green);
|
||||
self[color_index::DIM_YELLOW] = dim(colors.normal.yellow);
|
||||
self[color_index::DIM_BLUE] = dim(colors.normal.blue);
|
||||
self[color_index::DIM_MAGENTA] = dim(colors.normal.magenta);
|
||||
self[color_index::DIM_CYAN] = dim(colors.normal.cyan);
|
||||
self[color_index::DIM_WHITE] = dim(colors.normal.white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill_cube(&mut self, colors: &Colors) {
|
||||
let mut index: usize = 16;
|
||||
// Build colors.
|
||||
for r in 0..6 {
|
||||
for g in 0..6 {
|
||||
for b in 0..6 {
|
||||
// Override colors 16..232 with the config (if present).
|
||||
if let Some(indexed_color) = colors
|
||||
.indexed_colors
|
||||
.iter()
|
||||
.find(|ic| ic.index() == index as u8)
|
||||
{
|
||||
self[index] = indexed_color.color;
|
||||
} else {
|
||||
self[index] = ColorU::new(
|
||||
if r == 0 { 0 } else { r * 40 + 55 },
|
||||
if g == 0 { 0 } else { g * 40 + 55 },
|
||||
if b == 0 { 0 } else { b * 40 + 55 },
|
||||
255,
|
||||
);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_assert!(index == 232);
|
||||
}
|
||||
|
||||
pub fn fill_gray_ramp(&mut self, colors: &Colors) {
|
||||
let mut index: usize = 232;
|
||||
|
||||
for i in 0..24 {
|
||||
// Index of the color is number of named colors + number of cube colors + i.
|
||||
let color_index = 16 + 216 + i;
|
||||
|
||||
// Override colors 232..256 with the config (if present).
|
||||
if let Some(indexed_color) = colors
|
||||
.indexed_colors
|
||||
.iter()
|
||||
.find(|ic| ic.index() == color_index)
|
||||
{
|
||||
self[index] = indexed_color.color;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = i * 10 + 8;
|
||||
self[index] = ColorU::new(value, value, value, 255);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
debug_assert!(index == 256);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for List {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("List[..]")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PrimaryColors {
|
||||
pub foreground: ColorU,
|
||||
pub background: ColorU,
|
||||
pub bright_foreground: Option<ColorU>,
|
||||
pub dim_foreground: Option<ColorU>,
|
||||
}
|
||||
|
||||
impl PrimaryColors {
|
||||
pub fn new(foreground: ColorU, background: ColorU) -> Self {
|
||||
PrimaryColors {
|
||||
foreground,
|
||||
background,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrimaryColors {
|
||||
fn default() -> Self {
|
||||
PrimaryColors {
|
||||
background: ColorU {
|
||||
r: 0x1d,
|
||||
g: 0x1f,
|
||||
b: 0x21,
|
||||
a: 0xff,
|
||||
},
|
||||
foreground: ColorU {
|
||||
r: 0xc5,
|
||||
g: 0xc8,
|
||||
b: 0xc6,
|
||||
a: 0xff,
|
||||
},
|
||||
bright_foreground: Default::default(),
|
||||
dim_foreground: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NormalColors {
|
||||
pub black: ColorU,
|
||||
pub red: ColorU,
|
||||
pub green: ColorU,
|
||||
pub yellow: ColorU,
|
||||
pub blue: ColorU,
|
||||
pub magenta: ColorU,
|
||||
pub cyan: ColorU,
|
||||
pub white: ColorU,
|
||||
}
|
||||
|
||||
impl NormalColors {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
black: ColorU,
|
||||
red: ColorU,
|
||||
green: ColorU,
|
||||
yellow: ColorU,
|
||||
blue: ColorU,
|
||||
magenta: ColorU,
|
||||
cyan: ColorU,
|
||||
white: ColorU,
|
||||
) -> Self {
|
||||
NormalColors {
|
||||
black,
|
||||
red,
|
||||
green,
|
||||
yellow,
|
||||
blue,
|
||||
magenta,
|
||||
cyan,
|
||||
white,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<AnsiColors> for NormalColors {
|
||||
fn from(colors: AnsiColors) -> Self {
|
||||
Self::new(
|
||||
colors.black.into(),
|
||||
colors.red.into(),
|
||||
colors.green.into(),
|
||||
colors.yellow.into(),
|
||||
colors.blue.into(),
|
||||
colors.magenta.into(),
|
||||
colors.cyan.into(),
|
||||
colors.white.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NormalColors {
|
||||
fn default() -> Self {
|
||||
NormalColors {
|
||||
black: ColorU {
|
||||
r: 0x1d,
|
||||
g: 0x1f,
|
||||
b: 0x21,
|
||||
a: 0xff,
|
||||
},
|
||||
red: ColorU {
|
||||
r: 0xcc,
|
||||
g: 0x66,
|
||||
b: 0x66,
|
||||
a: 0xff,
|
||||
},
|
||||
green: ColorU {
|
||||
r: 0xb5,
|
||||
g: 0xbd,
|
||||
b: 0x68,
|
||||
a: 0xff,
|
||||
},
|
||||
yellow: ColorU {
|
||||
r: 0xf0,
|
||||
g: 0xc6,
|
||||
b: 0x74,
|
||||
a: 0xff,
|
||||
},
|
||||
blue: ColorU {
|
||||
r: 0x81,
|
||||
g: 0xa2,
|
||||
b: 0xbe,
|
||||
a: 0xff,
|
||||
},
|
||||
magenta: ColorU {
|
||||
r: 0xb2,
|
||||
g: 0x94,
|
||||
b: 0xbb,
|
||||
a: 0xff,
|
||||
},
|
||||
cyan: ColorU {
|
||||
r: 0x8a,
|
||||
g: 0xbe,
|
||||
b: 0xb7,
|
||||
a: 0xff,
|
||||
},
|
||||
white: ColorU {
|
||||
r: 0xc5,
|
||||
g: 0xc8,
|
||||
b: 0xc6,
|
||||
a: 0xff,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BrightColors {
|
||||
pub black: ColorU,
|
||||
pub red: ColorU,
|
||||
pub green: ColorU,
|
||||
pub yellow: ColorU,
|
||||
pub blue: ColorU,
|
||||
pub magenta: ColorU,
|
||||
pub cyan: ColorU,
|
||||
pub white: ColorU,
|
||||
}
|
||||
|
||||
impl BrightColors {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
black: ColorU,
|
||||
red: ColorU,
|
||||
green: ColorU,
|
||||
yellow: ColorU,
|
||||
blue: ColorU,
|
||||
magenta: ColorU,
|
||||
cyan: ColorU,
|
||||
white: ColorU,
|
||||
) -> Self {
|
||||
BrightColors {
|
||||
black,
|
||||
red,
|
||||
green,
|
||||
yellow,
|
||||
blue,
|
||||
magenta,
|
||||
cyan,
|
||||
white,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<AnsiColors> for BrightColors {
|
||||
fn from(colors: AnsiColors) -> Self {
|
||||
Self::new(
|
||||
colors.black.into(),
|
||||
colors.red.into(),
|
||||
colors.green.into(),
|
||||
colors.yellow.into(),
|
||||
colors.blue.into(),
|
||||
colors.magenta.into(),
|
||||
colors.cyan.into(),
|
||||
colors.white.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrightColors {
|
||||
fn default() -> Self {
|
||||
BrightColors {
|
||||
black: ColorU {
|
||||
r: 0x66,
|
||||
g: 0x66,
|
||||
b: 0x66,
|
||||
a: 0xff,
|
||||
},
|
||||
red: ColorU {
|
||||
r: 0xd5,
|
||||
g: 0x4e,
|
||||
b: 0x53,
|
||||
a: 0xff,
|
||||
},
|
||||
green: ColorU {
|
||||
r: 0xb9,
|
||||
g: 0xca,
|
||||
b: 0x4a,
|
||||
a: 0xff,
|
||||
},
|
||||
yellow: ColorU {
|
||||
r: 0xe7,
|
||||
g: 0xc5,
|
||||
b: 0x47,
|
||||
a: 0xff,
|
||||
},
|
||||
blue: ColorU {
|
||||
r: 0x7a,
|
||||
g: 0xa6,
|
||||
b: 0xda,
|
||||
a: 0xff,
|
||||
},
|
||||
magenta: ColorU {
|
||||
r: 0xc3,
|
||||
g: 0x97,
|
||||
b: 0xd8,
|
||||
a: 0xff,
|
||||
},
|
||||
cyan: ColorU {
|
||||
r: 0x70,
|
||||
g: 0xc0,
|
||||
b: 0xb1,
|
||||
a: 0xff,
|
||||
},
|
||||
white: ColorU {
|
||||
r: 0xea,
|
||||
g: 0xea,
|
||||
b: 0xea,
|
||||
a: 0xff,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DimColors {
|
||||
pub black: ColorU,
|
||||
pub red: ColorU,
|
||||
pub green: ColorU,
|
||||
pub yellow: ColorU,
|
||||
pub blue: ColorU,
|
||||
pub magenta: ColorU,
|
||||
pub cyan: ColorU,
|
||||
pub white: ColorU,
|
||||
}
|
||||
|
||||
impl Default for DimColors {
|
||||
fn default() -> Self {
|
||||
DimColors {
|
||||
black: ColorU {
|
||||
r: 0x13,
|
||||
g: 0x14,
|
||||
b: 0x15,
|
||||
a: 0xff,
|
||||
},
|
||||
red: ColorU {
|
||||
r: 0x86,
|
||||
g: 0x43,
|
||||
b: 0x43,
|
||||
a: 0xff,
|
||||
},
|
||||
green: ColorU {
|
||||
r: 0x77,
|
||||
g: 0x7c,
|
||||
b: 0x44,
|
||||
a: 0xff,
|
||||
},
|
||||
yellow: ColorU {
|
||||
r: 0x9e,
|
||||
g: 0x82,
|
||||
b: 0x4c,
|
||||
a: 0xff,
|
||||
},
|
||||
blue: ColorU {
|
||||
r: 0x55,
|
||||
g: 0x6a,
|
||||
b: 0x7d,
|
||||
a: 0xff,
|
||||
},
|
||||
magenta: ColorU {
|
||||
r: 0x75,
|
||||
g: 0x61,
|
||||
b: 0x7b,
|
||||
a: 0xff,
|
||||
},
|
||||
cyan: ColorU {
|
||||
r: 0x5b,
|
||||
g: 0x7d,
|
||||
b: 0x78,
|
||||
a: 0xff,
|
||||
},
|
||||
white: ColorU {
|
||||
r: 0x82,
|
||||
g: 0x84,
|
||||
b: 0x82,
|
||||
a: 0xff,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
|
||||
struct ColorIndex(u8);
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IndexedColor {
|
||||
pub color: ColorU,
|
||||
|
||||
index: ColorIndex,
|
||||
}
|
||||
|
||||
impl IndexedColor {
|
||||
#[inline]
|
||||
pub fn index(&self) -> u8 {
|
||||
self.index.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for List {
|
||||
type Output = ColorU;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self.0[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for List {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
&mut self.0[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<u8> for List {
|
||||
type Output = ColorU;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, idx: u8) -> &Self::Output {
|
||||
&self.0[idx as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<u8> for List {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, idx: u8) -> &mut Self::Output {
|
||||
&mut self.0[idx as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for OverrideList {
|
||||
type Output = Option<ColorU>;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
&self.0[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for OverrideList {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
&mut self.0[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<u8> for OverrideList {
|
||||
type Output = Option<ColorU>;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, idx: u8) -> &Self::Output {
|
||||
&self.0[idx as usize]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<u8> for OverrideList {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, idx: u8) -> &mut Self::Output {
|
||||
&mut self.0[idx as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a dimmed version of `color` based on a fixed dim-factor
|
||||
/// that is suitable for terminal colors.
|
||||
pub(super) fn dim(color: ColorU) -> ColorU {
|
||||
mult_coloru(color, DIM_FACTOR)
|
||||
}
|
||||
|
||||
fn mult_coloru(color: ColorU, rhs: f32) -> ColorU {
|
||||
let r = (color.r as f32 * rhs).clamp(0., 255.);
|
||||
let g = (color.g as f32 * rhs).clamp(0., 255.);
|
||||
let b = (color.b as f32 * rhs).clamp(0., 255.);
|
||||
ColorU::new(r as u8, g as u8, b as u8, color.a)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashSet;
|
||||
|
||||
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.
|
||||
pub static ref COMMAND_CORRECTIONS_PREFERRED_DENYLIST: HashSet<&'static str> = HashSet::from([
|
||||
"NoCommand",
|
||||
"CdMkdir",
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use warp_completer::completer::{CommandOutput, GeneratorContext};
|
||||
|
||||
use crate::completer::SessionContext;
|
||||
|
||||
/// Given a command and a session context, run the command and parse it as a dynamic command.
|
||||
/// Returns a vector of dynamic enum completion suggestions.
|
||||
pub(crate) async fn run_dynamic_enum_command(
|
||||
command: &str,
|
||||
ctx: &SessionContext,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
ctx.execute_command_at_pwd(command, None)
|
||||
.await
|
||||
.and_then(|output| parse_dynamic_command(&output))
|
||||
}
|
||||
|
||||
/// Parse the output of a dynamic enum suggestion command. Right now, this function
|
||||
/// trims each row and splits on newlines.
|
||||
fn parse_dynamic_command(output: &CommandOutput) -> anyhow::Result<Vec<String>> {
|
||||
match output.status {
|
||||
warp_completer::completer::CommandExitStatus::Success => {
|
||||
let output = String::from_utf8_lossy(output.output());
|
||||
let cleaned_output: Vec<String> = output
|
||||
.split("\n")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(cleaned_output)
|
||||
}
|
||||
warp_completer::completer::CommandExitStatus::Failure => {
|
||||
Err(anyhow::anyhow!("Command exited with failure code"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use enclose::enclose;
|
||||
use itertools::Itertools as _;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_graphql::billing::AddonCreditsOption;
|
||||
use warpui::elements::{
|
||||
Border, ChildView, Container, CrossAxisAlignment, Empty, Flex, HighlightedHyperlink,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent as _, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity as _, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::menu::MenuItemFields;
|
||||
use crate::modal::{Modal, ModalEvent, MODAL_PADDING, MODAL_WIDTH};
|
||||
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
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::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
|
||||
const DENOMINATION_DROPDOWN_WIDTH: f32 = MODAL_WIDTH - 2. * MODAL_PADDING;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStates {
|
||||
enable_button: MouseStateHandle,
|
||||
cancel_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// The body content of the enable auto-reload modal
|
||||
pub struct EnableAutoReloadModalBody {
|
||||
mouse_states: MouseStates,
|
||||
denomination_dropdown: ViewHandle<Dropdown<Action>>,
|
||||
addon_credits_options: Vec<AddonCreditsOption>,
|
||||
selected_denomination_index: usize,
|
||||
update_workspace_settings_loading: bool,
|
||||
}
|
||||
|
||||
/// The main modal that wraps the body
|
||||
pub struct EnableAutoReloadModal {
|
||||
modal: ViewHandle<Modal<EnableAutoReloadModalBody>>,
|
||||
}
|
||||
|
||||
/// Called when user clicks the 'x' OR cancel button
|
||||
fn send_auto_reload_dismissed_telemetry<V: View>(ctx: &mut ViewContext<V>) {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::AutoReloadModalClosed {
|
||||
action: AutoReloadModalAction::Dismissed,
|
||||
selected_credits: None,
|
||||
banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(),
|
||||
post_purchase_modal_flag_enabled: FeatureFlag::BuildPlanAutoReloadPostPurchaseModal
|
||||
.is_enabled(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
impl EnableAutoReloadModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&PricingInfoModel::handle(ctx),
|
||||
|me, _, event, ctx| match event {
|
||||
PricingInfoModelEvent::PricingInfoUpdated => {
|
||||
me.update_addon_credits_options(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&UserWorkspaces::handle(ctx),
|
||||
|me, _handle, event, ctx| {
|
||||
match event {
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
|
||||
if me.update_workspace_settings_loading {
|
||||
me.update_workspace_settings_loading = false;
|
||||
|
||||
// Emit telemetry for successful auto-reload enablement
|
||||
let selected_credits = me
|
||||
.addon_credits_options
|
||||
.get(me.selected_denomination_index)
|
||||
.map(|option| option.credits);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::AutoReloadModalClosed {
|
||||
action: AutoReloadModalAction::EnabledAutoReload,
|
||||
selected_credits,
|
||||
banner_toggle_flag_enabled:
|
||||
FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(),
|
||||
post_purchase_modal_flag_enabled:
|
||||
FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled(),
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
||||
message: "Auto-reload settings updated".to_string(),
|
||||
flavor: ToastFlavor::Success,
|
||||
});
|
||||
ctx.emit(EnableAutoReloadModalBodyEvent::Close);
|
||||
}
|
||||
}
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err) => {
|
||||
if me.update_workspace_settings_loading {
|
||||
me.update_workspace_settings_loading = false;
|
||||
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
||||
message: "Failed to enable auto-reload. Please try updating your settings in Billing & usage.".to_string(),
|
||||
flavor: ToastFlavor::Error,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let denomination_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(DENOMINATION_DROPDOWN_WIDTH);
|
||||
dropdown.set_menu_width(DENOMINATION_DROPDOWN_WIDTH, ctx);
|
||||
dropdown
|
||||
});
|
||||
|
||||
let mut me = Self {
|
||||
mouse_states: Default::default(),
|
||||
denomination_dropdown,
|
||||
addon_credits_options: Default::default(),
|
||||
selected_denomination_index: 0,
|
||||
update_workspace_settings_loading: false,
|
||||
};
|
||||
me.update_addon_credits_options(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
fn update_addon_credits_options(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.addon_credits_options = PricingInfoModel::as_ref(ctx)
|
||||
.addon_credits_options()
|
||||
.map(|opts| opts.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
let base_rate = self
|
||||
.addon_credits_options
|
||||
.first()
|
||||
.map_or(0., |option| option.rate());
|
||||
let items = self
|
||||
.addon_credits_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, option)| {
|
||||
let primary_text = format!(
|
||||
"${:.0} / {} credits",
|
||||
option.price_usd_cents as f32 / 100.,
|
||||
option.credits
|
||||
);
|
||||
let discount_percent = if base_rate > 0.0 {
|
||||
let actual_rate = option.rate();
|
||||
((base_rate - actual_rate) / base_rate * 100.0).round() as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if discount_percent > 0 {
|
||||
MenuItemFields::new_with_custom_label(
|
||||
Arc::new(enclose!((primary_text) move |is_selected, is_hovered, appearance, _| {
|
||||
let text_color = appearance.theme().main_text_color(
|
||||
if is_selected || is_hovered {
|
||||
appearance.theme().accent()
|
||||
} else {
|
||||
appearance.theme().surface_1()
|
||||
}
|
||||
);
|
||||
let main_text = Text::new_inline(
|
||||
primary_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(text_color.into())
|
||||
.finish();
|
||||
|
||||
let discount_badge = create_discount_badge(discount_percent, appearance);
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(main_text)
|
||||
.with_child(discount_badge)
|
||||
.finish()
|
||||
})),
|
||||
Some(primary_text),
|
||||
)
|
||||
.with_on_select_action(Action::SelectDenomination(index).into())
|
||||
.into_item()
|
||||
} else {
|
||||
MenuItemFields::new(primary_text.clone())
|
||||
.with_on_select_action(Action::SelectDenomination(index).into())
|
||||
.into_item()
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
self.denomination_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_rich_items(items, ctx);
|
||||
dropdown.set_selected_by_index(self.selected_denomination_index, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_content(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let explanation_fragments = vec![
|
||||
FormattedTextFragment::plain_text("When enabled, "),
|
||||
FormattedTextFragment::bold("auto-reload"),
|
||||
FormattedTextFragment::plain_text(
|
||||
" will automatically purchase your selected package when you run out. ",
|
||||
),
|
||||
FormattedTextFragment::hyperlink(
|
||||
"Learn more",
|
||||
"https://docs.warp.dev/support-and-community/plans-and-billing/add-on-credits#id-2.-enable-auto-reload",
|
||||
),
|
||||
];
|
||||
let explanation_text = warpui::elements::FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(explanation_fragments)]),
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, theme.surface_1()),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(|hyperlink_lens, _event, ctx| {
|
||||
match hyperlink_lens {
|
||||
warpui::elements::HyperlinkLens::Url(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
warpui::elements::HyperlinkLens::Action(_action_ref) => {}
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
let denomination_dropdown =
|
||||
Container::new(ChildView::new(&self.denomination_dropdown).finish())
|
||||
.with_vertical_padding(16.)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(explanation_text)
|
||||
.with_child(denomination_dropdown)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.mouse_states.cancel_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Semibold),
|
||||
padding: Some(Coords {
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
left: 12.,
|
||||
right: 12.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label("Cancel".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(Action::Cancel);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let button_text = if self.update_workspace_settings_loading {
|
||||
"Saving...".to_string()
|
||||
} else {
|
||||
"Enable".to_string()
|
||||
};
|
||||
|
||||
let mut enable_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.mouse_states.enable_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_weight: Some(Weight::Semibold),
|
||||
padding: Some(Coords {
|
||||
top: 6.,
|
||||
bottom: 6.,
|
||||
left: 12.,
|
||||
right: 12.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label(button_text)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(Action::Enable);
|
||||
});
|
||||
|
||||
if self.update_workspace_settings_loading {
|
||||
enable_button = enable_button.disable();
|
||||
}
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_spacing(8.)
|
||||
.with_child(cancel_button)
|
||||
.with_child(enable_button.finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EnableAutoReloadModalBodyEvent {
|
||||
Close,
|
||||
ShowToast {
|
||||
message: String,
|
||||
flavor: ToastFlavor,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for EnableAutoReloadModalBody {
|
||||
type Event = EnableAutoReloadModalBodyEvent;
|
||||
}
|
||||
|
||||
impl View for EnableAutoReloadModalBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"EnableAutoReloadModalBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let content = Container::new(self.render_content(appearance))
|
||||
.with_horizontal_padding(MODAL_PADDING)
|
||||
.with_margin_top(0.) // let the header padding handle the top margin
|
||||
.finish();
|
||||
|
||||
let separator = Container::new(Empty::new().finish())
|
||||
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
|
||||
.finish();
|
||||
|
||||
let buttons = Container::new(self.render_buttons(appearance))
|
||||
.with_horizontal_padding(MODAL_PADDING)
|
||||
.with_vertical_padding(12.)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(content)
|
||||
.with_child(separator)
|
||||
.with_child(buttons)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Action {
|
||||
SelectDenomination(usize),
|
||||
Cancel,
|
||||
Enable,
|
||||
}
|
||||
|
||||
impl warpui::TypedActionView for EnableAutoReloadModalBody {
|
||||
type Action = Action;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Action::SelectDenomination(index) => {
|
||||
self.selected_denomination_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
Action::Cancel => {
|
||||
send_auto_reload_dismissed_telemetry(ctx);
|
||||
ctx.emit(EnableAutoReloadModalBodyEvent::Close);
|
||||
}
|
||||
Action::Enable => {
|
||||
let workspaces = UserWorkspaces::as_ref(ctx);
|
||||
let Some(team_uid) = workspaces.current_team_uid() else {
|
||||
ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast {
|
||||
message: "Oops, something went wrong; your team's data could not be found."
|
||||
.to_string(),
|
||||
flavor: ToastFlavor::Error,
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
// Set loading state before making the API call
|
||||
self.update_workspace_settings_loading = true;
|
||||
ctx.notify();
|
||||
|
||||
UserWorkspaces::handle(ctx).update(ctx, move |user_workspaces, ctx| {
|
||||
user_workspaces.update_addon_credits_settings(
|
||||
team_uid,
|
||||
Some(true),
|
||||
// TODO: consider allowing user to set max monthly spend too in this modal
|
||||
None,
|
||||
Some(self.addon_credits_options[self.selected_denomination_index].credits),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal wrapper
|
||||
impl EnableAutoReloadModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let body = ctx.add_typed_action_view(EnableAutoReloadModalBody::new);
|
||||
|
||||
let modal = ctx.add_typed_action_view(|ctx| {
|
||||
Modal::new(Some("Enable auto reload?".to_string()), body.clone(), ctx).with_body_style(
|
||||
UiComponentStyles {
|
||||
// Padding of 0 here since we add a horizontal bar that needs to span the full width in the body
|
||||
// So we handle padding in the body itself
|
||||
padding: Some(Coords::uniform(0.)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&modal, |_, _, event, ctx| match event {
|
||||
ModalEvent::Close => {
|
||||
// "x" clicked
|
||||
send_auto_reload_dismissed_telemetry(ctx);
|
||||
ctx.emit(EnableAutoReloadModalEvent::Close);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&body, |_, _, event, ctx| match event {
|
||||
EnableAutoReloadModalBodyEvent::Close => {
|
||||
ctx.emit(EnableAutoReloadModalEvent::Close);
|
||||
}
|
||||
EnableAutoReloadModalBodyEvent::ShowToast { message, flavor } => {
|
||||
ctx.emit(EnableAutoReloadModalEvent::ShowToast {
|
||||
message: message.clone(),
|
||||
flavor: *flavor,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self { modal }
|
||||
}
|
||||
|
||||
pub fn set_selected_denomination_by_credits(
|
||||
&mut self,
|
||||
credits: i32,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.modal.update(ctx, |modal, ctx| {
|
||||
modal.body().update(ctx, |body, ctx| {
|
||||
if let Some((index, _)) = body
|
||||
.addon_credits_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, option)| option.credits == credits)
|
||||
{
|
||||
body.selected_denomination_index = index;
|
||||
// Update the dropdown to reflect the new selection
|
||||
body.denomination_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_index(index, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EnableAutoReloadModalEvent {
|
||||
Close,
|
||||
ShowToast {
|
||||
message: String,
|
||||
flavor: ToastFlavor,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for EnableAutoReloadModal {
|
||||
type Event = EnableAutoReloadModalEvent;
|
||||
}
|
||||
|
||||
impl warpui::TypedActionView for EnableAutoReloadModal {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
impl View for EnableAutoReloadModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"EnableAutoReloadModal"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.modal).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
use std::fmt;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::num::ParseIntError;
|
||||
use std::string::FromUtf8Error;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use instant::Instant;
|
||||
|
||||
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::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 {
|
||||
CompletionsFinished(Vec<ShellCompletion>),
|
||||
MouseCursorDirty,
|
||||
Title(String),
|
||||
VisibleBootstrapBlock,
|
||||
/// Performs the minimal work necessary to show that a block has completed.
|
||||
/// Treat this as a performance-sensitive path.
|
||||
BlockCompleted(BlockCompletedEvent),
|
||||
/// Meant for more expensive operations that can be delayed without negatively
|
||||
/// affecting the UI.
|
||||
AfterBlockCompleted(AfterBlockCompletedEvent),
|
||||
/// Send on DProtoHook::Preexec, but only for blocks after bootstrapping
|
||||
AfterBlockStarted {
|
||||
block_id: BlockId,
|
||||
command: String,
|
||||
is_for_in_band_command: bool,
|
||||
},
|
||||
/// Sent when a new block is created.
|
||||
BlockMetadataReceived(BlockMetadataReceivedEvent),
|
||||
/// Sent after a background block is started and added to the block list.
|
||||
BackgroundBlockStarted,
|
||||
ClipboardStore(ClipboardType, String),
|
||||
ClipboardLoad(
|
||||
ClipboardType,
|
||||
Arc<dyn Fn(&str) -> String + Sync + Send + 'static>,
|
||||
),
|
||||
CursorBlinkingChange(bool),
|
||||
TerminalClear,
|
||||
Bell,
|
||||
Exit {
|
||||
reason: ExitReason,
|
||||
},
|
||||
/// An indication that we are about to initiate an interactive SSH session
|
||||
/// (which may or may not use the SSH wrapper).
|
||||
PreInteractiveSSHSession,
|
||||
/// An indication that a successful SSH connection was initiated via the
|
||||
/// SSH wrapper. The argument is the name of the remote shell.
|
||||
SSH(String),
|
||||
/// Emitted when the remote shell for a session is about to exit, so
|
||||
/// per-session resources (e.g. the `ssh … remote-server-proxy` child that
|
||||
/// holds a multiplexed channel on the ControlMaster) can be torn down
|
||||
/// before the outer ssh tunnel starts closing.
|
||||
ExitShell {
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// Sent when the model detects an SSH ControlMaster error, which means that
|
||||
/// completions reliant on command execution will not work.
|
||||
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),
|
||||
/// Emitted when the active block's prompt has been updated.
|
||||
PromptUpdated,
|
||||
/// Emitted when the honor_ps1 state of the shell is out-of-sync with Warp's settings.
|
||||
/// This can happen in cases such as when the user changes between PS1 and Warp prompt inside
|
||||
/// of an SSH session (the bindkeys are sent to the SSH session but not the local session, so
|
||||
/// they are out-of-sync when the user exits SSH).
|
||||
HonorPS1OutOfSync,
|
||||
/// Emitted when the terminal model receives typeahead output from the PTY.
|
||||
/// "Typeahead" are characters that were written to the PTY during long-running command execution
|
||||
/// close to the end of the its execution, such that these characters were not actually read by
|
||||
/// the running program. The shell stores these characters, inserts them into its internal line
|
||||
/// buffer, and re-echoes them after Precmd.
|
||||
Typeahead,
|
||||
/// Emitted when the agent is tagged in or out of the active block.
|
||||
/// 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 {
|
||||
is_tagged_in: bool,
|
||||
},
|
||||
Handler(HandlerEvent),
|
||||
/// Emitted by the async remote server setup task to report intermediate
|
||||
/// state changes (e.g. Checking → Installing → Initializing) so the
|
||||
/// prompt can show stage-specific messages.
|
||||
RemoteServerSetupStateChanged {
|
||||
session_id: SessionId,
|
||||
state: RemoteServerSetupState,
|
||||
},
|
||||
/// Emitted when the remote server binary has been successfully checked or
|
||||
/// installed and is ready. The session is initialized independently on
|
||||
/// `Bootstrapped`; when the remote server later connects, the client is
|
||||
/// attached to the existing session's `RemoteServerCommandExecutor` via
|
||||
/// the `RemoteServerManagerEvent::SessionConnected` subscription in
|
||||
/// `Sessions::new`.
|
||||
RemoteServerReady {
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// Emitted when the remote server setup failed. The session falls back to
|
||||
/// the ControlMaster-based `RemoteCommandExecutor`.
|
||||
RemoteServerFailed {
|
||||
session_id: SessionId,
|
||||
error: String,
|
||||
},
|
||||
/// Emitted when the assisted auto-update has completed and we're ready to
|
||||
/// relaunch the app.
|
||||
FinishUpdate(FinishUpdateValue),
|
||||
TextSelectionChanged,
|
||||
ShellSpawned(ShellType),
|
||||
SendCompletionsPrompt,
|
||||
ImageReceived {
|
||||
image_id: u32,
|
||||
image_data: Vec<u8>,
|
||||
image_protocol: ImageProtocol,
|
||||
},
|
||||
BootstrapPrecmdDone,
|
||||
/// A pluggable notification triggered via OSC 9 or OSC 777 escape sequences.
|
||||
/// External programs can use this to trigger notifications in Warp.
|
||||
///
|
||||
/// References:
|
||||
/// - OSC 9: <https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands>
|
||||
/// - OSC 777: <https://codeberg.org/dnkl/foot/wiki/Notify>
|
||||
PluggableNotification {
|
||||
title: Option<String>,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InitSubshellEvent {
|
||||
pub shell_type: ShellType,
|
||||
pub uname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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)]
|
||||
pub enum TerminalMode {
|
||||
AltScreen,
|
||||
BlockList,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SshLoginStatus {
|
||||
/// We have some evidence login is complete but should check again.
|
||||
RecheckBeforeWarpifying,
|
||||
/// We have high confidence login is complete.
|
||||
ReadyToWarpify,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InitShellEvent {
|
||||
pub pending_session_info: SessionInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BootstrappedEvent {
|
||||
/// The command which spawned the shell.
|
||||
pub spawning_command: String,
|
||||
// This is wrapped in an `Box` to surpress clippy's large-enum-variant warning, not because it
|
||||
// functionally needs to be wrapped in an `Box`.
|
||||
pub session_info: Box<SessionInfo>,
|
||||
pub restored_block_commands: Vec<HistoryEntry>,
|
||||
/// The time we spent sourcing the user's rcfiles, in seconds. This may be
|
||||
/// None if the information was not provided by the shell.
|
||||
pub rcfiles_duration_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlockCompletedEvent {
|
||||
/// This will be None when we don't want to collect telemetry
|
||||
/// for this block's latency.
|
||||
pub block_latency_data: Option<BlockLatencyData>,
|
||||
pub block_type: BlockType,
|
||||
pub num_secrets_obfuscated: usize,
|
||||
pub block_index: BlockIndex,
|
||||
pub block_id: BlockId,
|
||||
pub session_id: Option<SessionId>,
|
||||
pub restored_block_was_local: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AfterBlockCompletedEvent {
|
||||
/// The delay from the CommandFinished ansi hook to the Precmd hook.
|
||||
/// This value is only provided for the blocks that the user directly
|
||||
/// executes (so it's not provided if this is a restored block from session
|
||||
/// restoration or a bootstrapping block).
|
||||
pub command_finished_to_precmd_delay: Option<Duration>,
|
||||
pub block_type: BlockType,
|
||||
pub num_secrets_obfuscated: usize,
|
||||
|
||||
/// If the completed block was a workflow, this is its id.
|
||||
pub cloud_workflow_id: Option<SyncId>,
|
||||
|
||||
/// If the completed block had an env var object associated.
|
||||
pub cloud_env_var_collection_id: Option<SyncId>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlockLatencyData {
|
||||
pub command: &'static str,
|
||||
/// When the block's command grid was started (i.e. when the user hit enter).
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// Different types of blocks. `User` is for normal execution.
|
||||
/// Everything else is earlier in the bootstrapping sequence.
|
||||
pub enum BlockType {
|
||||
/// When there are blocks that finish in the bootstrap sequence,
|
||||
/// we don't want to propagate the event around our app.
|
||||
BootstrapHidden,
|
||||
/// This is a special case where the user's rcfiles resulted in outputs. We
|
||||
/// will want some of the view logic to execute, and not all of it.
|
||||
BootstrapVisible(Arc<SerializedBlock>),
|
||||
/// This was a block we restored through session restoration.
|
||||
Restored,
|
||||
/// This was a block created for execution of an in-band command.
|
||||
InBandCommand,
|
||||
/// This is a normal block that the user executed.
|
||||
User(UserBlockCompleted),
|
||||
|
||||
/// This is a block containing background process output.
|
||||
Background(Arc<SerializedBlock>),
|
||||
|
||||
/// This is a block containing static/hardcoded content (e.g. the subshell Warpification
|
||||
/// welcome block).
|
||||
Static,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
pub fn is_bootstrap_block(&self) -> bool {
|
||||
matches!(self, Self::BootstrapHidden | Self::BootstrapVisible(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// A notification that the metadata for the active block & prompt is now
|
||||
/// available.
|
||||
pub struct BlockMetadataReceivedEvent {
|
||||
pub block_metadata: BlockMetadata,
|
||||
pub block_index: BlockIndex,
|
||||
/// Whether the previous block was an in-band command.
|
||||
pub is_after_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 {
|
||||
pub index: BlockIndex,
|
||||
|
||||
pub serialized_block: Arc<SerializedBlock>,
|
||||
|
||||
/// The input lines for a block without any escape sequences.
|
||||
pub command: String,
|
||||
|
||||
/// The command with secrets obfuscated.
|
||||
pub command_with_obfuscated_secrets: String,
|
||||
|
||||
/// The output lines for a block without any escape sequences.
|
||||
/// They are truncated to the number of lines specificed by the caller.
|
||||
pub output_truncated: String,
|
||||
|
||||
/// The output lines for a block without any escape sequences.
|
||||
/// They are truncated to the number of lines specificed by the caller.
|
||||
/// Forced secrets to be obfuscated as well.
|
||||
pub output_truncated_with_obfuscated_secrets: String,
|
||||
|
||||
/// `true` if the block was run as a requested command or was part of a CLI subagent interaction.
|
||||
pub was_part_of_agent_interaction: bool,
|
||||
|
||||
/// Time that we started the command grid (i.e. immediately after the user
|
||||
/// hit enter).
|
||||
pub started_at: Option<Instant>,
|
||||
|
||||
/// The number of lines in the output grid when it was finished.
|
||||
pub num_output_lines: u64,
|
||||
|
||||
/// The number of lines of output that were truncated while the block
|
||||
/// was active and receiving output.
|
||||
pub num_output_lines_truncated: u64,
|
||||
}
|
||||
|
||||
/// Emitted upon completion of an executor command that goes through the pty, such as the
|
||||
/// InBandCommandExecutor or the TmuxCommandExecutor.
|
||||
#[derive(Clone)]
|
||||
pub struct ExecutedExecutorCommandEvent {
|
||||
pub command_id: String,
|
||||
pub exit_code: usize,
|
||||
pub output: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ExecutedExecutorCommandEvent {
|
||||
/// Parses the given `payload` (expected to be the payload of a generator output OSC) into a
|
||||
/// `ExecutedGeneratorCommandValue`.
|
||||
///
|
||||
/// The given `string` is expected to follow the following format:
|
||||
/// <commmand_id>;<output>;<exit_code>
|
||||
///
|
||||
/// Returns a `ParseGeneratorCommandValueError` if payload cannot be successfully parsed.
|
||||
///
|
||||
pub fn parse_generator_payload(payload: Vec<u8>) -> Result<Self, ParseGeneratorOutputError> {
|
||||
// Break the payload apart at the first and last semicolons.
|
||||
let mut payload_initial_split = payload.splitn(2, |&byte| byte == b';');
|
||||
|
||||
let Some(before_first_semicolon) = payload_initial_split.next() else {
|
||||
return Err(ParseGeneratorOutputError::Corrupted);
|
||||
};
|
||||
|
||||
let Some(after_first_semicolon) = payload_initial_split.next() else {
|
||||
return Err(ParseGeneratorOutputError::Corrupted);
|
||||
};
|
||||
|
||||
let mut payload_final_split = after_first_semicolon.rsplitn(2, |&byte| byte == b';');
|
||||
let Some(after_final_semicolon) = payload_final_split.next() else {
|
||||
return Err(ParseGeneratorOutputError::Corrupted);
|
||||
};
|
||||
|
||||
let Some(payload_middle) = payload_final_split.next() else {
|
||||
return Err(ParseGeneratorOutputError::Corrupted);
|
||||
};
|
||||
|
||||
let command_id = String::from_utf8(before_first_semicolon.to_vec())
|
||||
.map_err(ParseGeneratorOutputError::Utf8DecodingFailure)?;
|
||||
|
||||
let exit_code = String::from_utf8(after_final_semicolon.to_vec())
|
||||
.map_err(ParseGeneratorOutputError::Utf8DecodingFailure)?
|
||||
.parse::<usize>()
|
||||
.map_err(ParseGeneratorOutputError::ExitCodeParseFailure)?;
|
||||
|
||||
// The output of the command remains as bytes. This is so we can operate on the bytes higher in
|
||||
// the stack if we need to, such as in the case of parsing out the zsh history file where we want to
|
||||
// transform the byte array before converting to a string.
|
||||
let output = payload_middle.to_vec();
|
||||
|
||||
Ok(Self {
|
||||
command_id,
|
||||
exit_code,
|
||||
output,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for ExecutedExecutorCommandEvent {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ExecutedExecutorCommandEvent")
|
||||
.field("command_id", &self.command_id)
|
||||
.field("exit_code", &self.exit_code)
|
||||
.field("output", &AsciiDebug(&self.output))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ParseGeneratorOutputError {
|
||||
#[error("Failed to parse exit code: {0:?}")]
|
||||
ExitCodeParseFailure(ParseIntError),
|
||||
#[error("Corrupted DCS. Should be of the format <command_id>;<exit_code>;<output>. ")]
|
||||
Corrupted,
|
||||
#[error("Failed to convert to Utf8: {0:?}")]
|
||||
Utf8DecodingFailure(FromUtf8Error),
|
||||
}
|
||||
|
||||
impl Debug for Event {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Event::CompletionsFinished(_) => write!(f, "CompletionsFinished"),
|
||||
Event::MouseCursorDirty => write!(f, "MouseCursorDirty"),
|
||||
Event::BlockCompleted(_) => write!(f, "BlockCompleted"),
|
||||
Event::AfterBlockCompleted(_) => write!(f, "AfterBlockCompleted"),
|
||||
Event::BlockMetadataReceived(event) => write!(
|
||||
f,
|
||||
"BlockStarted({:?}, Done bootstrapping: {:?})",
|
||||
event.block_metadata, event.is_done_bootstrapping
|
||||
),
|
||||
Event::AfterBlockStarted { .. } => write!(f, "BlockExecutionStarted"),
|
||||
Event::BackgroundBlockStarted => write!(f, "BackgroundBlockStarted"),
|
||||
Event::VisibleBootstrapBlock => write!(f, "VisibleBootstrapBlock"),
|
||||
Event::Title(title) => write!(f, "Title({title})"),
|
||||
Event::ClipboardStore(_, text) => write!(f, "ClipboardStore({text})"),
|
||||
Event::ClipboardLoad(_, _) => write!(f, "ClipboardLoad()"),
|
||||
Event::TerminalClear => write!(f, "TerminalClear"),
|
||||
Event::Bell => write!(f, "Bell"),
|
||||
Event::Exit { reason } => write!(f, "Exit({reason:?})"),
|
||||
Event::CursorBlinkingChange(blinking) => write!(f, "CursorBlinking({blinking})"),
|
||||
Event::PreInteractiveSSHSession => write!(f, "Pre-Interactive SSH Session"),
|
||||
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 {}",
|
||||
event.command_id, event.exit_code
|
||||
),
|
||||
Event::InitSubshell(event) => {
|
||||
write!(f, "InitSubshell({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::Handler(handler_event) => write!(f, "Handler({handler_event:?}))"),
|
||||
Event::RemoteServerSetupStateChanged {
|
||||
session_id,
|
||||
ref state,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"RemoteServerSetupStateChanged(session: {session_id:?}, state: {state:?})"
|
||||
)
|
||||
}
|
||||
Event::RemoteServerReady { session_id } => {
|
||||
write!(f, "RemoteServerReady(session: {session_id:?})")
|
||||
}
|
||||
Event::RemoteServerFailed {
|
||||
session_id,
|
||||
ref error,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"RemoteServerFailed(session: {session_id:?}, error: {error})"
|
||||
)
|
||||
}
|
||||
Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id),
|
||||
Event::TextSelectionChanged => write!(f, "TextSelectionChanged"),
|
||||
Event::ShellSpawned(shell_type) => write!(f, "ShellSpawned({shell_type:?})"),
|
||||
Event::SendCompletionsPrompt => write!(f, "SendCompletionsPrompt"),
|
||||
Event::ImageReceived { image_id, .. } => {
|
||||
write!(f, "ImageReceived(image_id: {image_id})")
|
||||
}
|
||||
Event::BootstrapPrecmdDone => write!(f, "BootstrapPrecmdDone"),
|
||||
Event::PluggableNotification { title, body } => {
|
||||
write!(f, "PluggableNotification(title: {title:?}, body: {body})")
|
||||
}
|
||||
Event::ExitShell { session_id } => {
|
||||
write!(f, "ExitShell(session: {session_id:?})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::terminal::event::Event as TerminalEvent;
|
||||
use async_channel::Sender;
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone)]
|
||||
pub struct ChannelEventListener {
|
||||
/// We have a dedicated channel for "wakeup"s because we throttle the receiver
|
||||
/// so that we can coalesce successive wakeup events during situations of high
|
||||
/// throughput (e.g. running `yes`).
|
||||
wakeups_tx: Sender<()>,
|
||||
terminal_events_tx: Sender<TerminalEvent>,
|
||||
pty_reads_tx: async_broadcast::Sender<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl ChannelEventListener {
|
||||
pub fn new(
|
||||
wakeups_tx: Sender<()>,
|
||||
terminal_events_tx: Sender<TerminalEvent>,
|
||||
pty_reads_tx: async_broadcast::Sender<Arc<Vec<u8>>>,
|
||||
) -> Self {
|
||||
ChannelEventListener {
|
||||
wakeups_tx,
|
||||
terminal_events_tx,
|
||||
pty_reads_tx,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
pub fn are_any_events_pending(&self) -> bool {
|
||||
!self.wakeups_tx.is_empty()
|
||||
|| !self.terminal_events_tx.is_empty()
|
||||
|| !self.pty_reads_tx.is_empty()
|
||||
}
|
||||
|
||||
pub fn send_wakeup_event(&self) {
|
||||
if let Err(e) = self.wakeups_tx.try_send(()) {
|
||||
log::warn!("Failed to send Wakeup event: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_terminal_event(&self, event: TerminalEvent) {
|
||||
if let Err(e) = self.terminal_events_tx.try_send(event) {
|
||||
let try_send_error_dbg = format!("{e:?}");
|
||||
log::warn!(
|
||||
"Failed to send Terminal event {:?}: {:?}",
|
||||
e.into_inner(),
|
||||
try_send_error_dbg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_handler_event(&self, event: HandlerEvent) {
|
||||
if let Err(e) = self
|
||||
.terminal_events_tx
|
||||
.try_send(TerminalEvent::Handler(event))
|
||||
{
|
||||
log::warn!("Failed to send Terminal Handler event {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_pty_read_event(&self, bytes: &[u8]) {
|
||||
// Don't bother sending the event if there aren't any
|
||||
// active receivers. This avoids an unnecessary allocation of the bytes vector.
|
||||
// Note that we don't simply close the sending side since receivers
|
||||
// might come alive at some point in the future.
|
||||
if self.pty_reads_tx.receiver_count() > 0 {
|
||||
if let Err(e) = self.pty_reads_tx.try_broadcast(Arc::new(bytes.to_vec())) {
|
||||
log::warn!("Failed to send pty read event: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod testing;
|
||||
|
||||
use crate::terminal::model::terminal_model::HandlerEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub use testing::*;
|
||||
@@ -0,0 +1,66 @@
|
||||
use async_channel::Sender;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::ChannelEventListener;
|
||||
use crate::terminal::event::Event as TerminalEvent;
|
||||
|
||||
pub struct ChannelEventListenerBuilder {
|
||||
wakeups_tx: Option<Sender<()>>,
|
||||
events_tx: Option<Sender<TerminalEvent>>,
|
||||
pty_bytes_read_tx: Option<async_broadcast::Sender<Arc<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ChannelEventListenerBuilder {
|
||||
fn new() -> Self {
|
||||
ChannelEventListenerBuilder {
|
||||
wakeups_tx: None,
|
||||
events_tx: None,
|
||||
pty_bytes_read_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_wakeups_tx(mut self, wakeups_tx: Sender<()>) -> Self {
|
||||
self.wakeups_tx = Some(wakeups_tx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_terminal_events_tx(mut self, events_tx: Sender<TerminalEvent>) -> Self {
|
||||
self.events_tx = Some(events_tx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_pty_bytes_read_tx(
|
||||
mut self,
|
||||
pty_bytes_read_tx: async_broadcast::Sender<Arc<Vec<u8>>>,
|
||||
) -> Self {
|
||||
self.pty_bytes_read_tx = Some(pty_bytes_read_tx);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> ChannelEventListener {
|
||||
ChannelEventListener::new(
|
||||
self.wakeups_tx.unwrap_or_else(|| {
|
||||
let (tx, _) = async_channel::unbounded();
|
||||
tx
|
||||
}),
|
||||
self.events_tx.unwrap_or_else(|| {
|
||||
let (tx, _) = async_channel::unbounded();
|
||||
tx
|
||||
}),
|
||||
self.pty_bytes_read_tx.unwrap_or_else(|| {
|
||||
let (tx, _) = async_broadcast::broadcast(1);
|
||||
tx
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelEventListener {
|
||||
pub fn new_for_test() -> Self {
|
||||
Self::builder_for_test().build()
|
||||
}
|
||||
|
||||
pub fn builder_for_test() -> ChannelEventListenerBuilder {
|
||||
ChannelEventListenerBuilder::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod model;
|
||||
|
||||
pub use model::{
|
||||
BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions, FindableRichContentView,
|
||||
RichContentMatchId, TerminalFindModel,
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
mod alt_screen;
|
||||
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 alt_screen::{run_find_on_alt_screen, AltScreenFindRun};
|
||||
use parking_lot::FairMutex;
|
||||
use settings::Setting as _;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
settings::InputModeSettings,
|
||||
terminal::model::{terminal_model::BlockIndex, TerminalModel},
|
||||
view_components::find::{FindEvent, FindModel},
|
||||
};
|
||||
|
||||
use crate::view_components::find::FindDirection;
|
||||
|
||||
use block_list::run_find_on_block_list;
|
||||
use rich_content::FindableRichContentHandle;
|
||||
|
||||
/// `TerminalView`-scoped model for the find bar.
|
||||
pub struct TerminalFindModel {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
|
||||
rich_content_views: HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
|
||||
|
||||
/// 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.
|
||||
block_list_find_run: Option<BlockListFindRun>,
|
||||
|
||||
/// `true` if the find bar is open.
|
||||
is_find_bar_open: bool,
|
||||
}
|
||||
|
||||
impl FindModel for TerminalFindModel {
|
||||
fn focused_match_index(&self) -> Option<usize> {
|
||||
if self.terminal_model.lock().is_alt_screen_active() {
|
||||
self.alt_screen_find_run
|
||||
.as_ref()
|
||||
.and_then(|run| run.focused_match_index())
|
||||
} else {
|
||||
self.block_list_find_run
|
||||
.as_ref()
|
||||
.and_then(|run| run.focused_match_index())
|
||||
}
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
if self.terminal_model.lock().is_alt_screen_active() {
|
||||
self.alt_screen_find_run
|
||||
.as_ref()
|
||||
.map(|run| run.matches().len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
self.block_list_find_run
|
||||
.as_ref()
|
||||
.map(|run| run.matches().count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_find_direction(&self, app: &AppContext) -> FindDirection {
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
match input_mode {
|
||||
InputMode::PinnedToBottom | InputMode::Waterfall => FindDirection::Up,
|
||||
InputMode::PinnedToTop => FindDirection::Down,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalFindModel {
|
||||
pub fn new(terminal_model: Arc<FairMutex<TerminalModel>>) -> Self {
|
||||
Self {
|
||||
terminal_model,
|
||||
rich_content_views: HashMap::new(),
|
||||
alt_screen_find_run: None,
|
||||
block_list_find_run: None,
|
||||
is_find_bar_open: false,
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
/// Returns `true` if the find bar is currently open.
|
||||
pub(crate) fn is_find_bar_open(&self) -> bool {
|
||||
self.is_find_bar_open
|
||||
}
|
||||
|
||||
/// Updates find bar visibility.
|
||||
pub(crate) fn set_is_find_bar_open(&mut self, is_open: bool) {
|
||||
self.is_find_bar_open = is_open;
|
||||
}
|
||||
|
||||
/// Returns the last find run for the alt screen.
|
||||
pub(crate) fn alt_screen_find_run(&self) -> Option<&AltScreenFindRun> {
|
||||
self.alt_screen_find_run.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the last find run for the blocklist.
|
||||
pub(crate) fn block_list_find_run(&self) -> Option<&BlockListFindRun> {
|
||||
self.block_list_find_run.as_ref()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.block_list_find_run.as_ref().map(|run| run.options())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs find with the given `options` on the alt screen or blocklist (depending on which is
|
||||
/// active).
|
||||
pub fn run_find(&mut self, options: FindOptions, ctx: &mut ModelContext<Self>) {
|
||||
if self.terminal_model.lock().is_alt_screen_active() {
|
||||
self.alt_screen_find_run = Some(run_find_on_alt_screen(
|
||||
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);
|
||||
}
|
||||
|
||||
/// Reruns find with the same options applied to the current run, to be called if terminal
|
||||
/// contents have changed since the last find run.
|
||||
pub fn rerun_find_on_active_grid(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.terminal_model.lock().is_alt_screen_active() {
|
||||
if let Some(old_find_state) = self.alt_screen_find_run.take() {
|
||||
self.alt_screen_find_run =
|
||||
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();
|
||||
|
||||
// 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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus the "next" match, depending on the given [`FindDirection`], in the active find run's
|
||||
/// list of matches.
|
||||
///
|
||||
/// If there is no focused match, focuses the first match in the list.
|
||||
pub fn focus_next_find_match(
|
||||
&mut self,
|
||||
find_direction: FindDirection,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.terminal_model.lock().is_alt_screen_active() {
|
||||
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(block_list_find_run) = self.block_list_find_run.as_mut() {
|
||||
let block_sort_direction = InputModeSettings::as_ref(ctx)
|
||||
.input_mode
|
||||
.value()
|
||||
.block_sort_direction();
|
||||
|
||||
block_list_find_run.focus_next_match(find_direction, block_sort_direction);
|
||||
}
|
||||
ctx.emit(FindEvent::UpdatedFocusedMatch);
|
||||
}
|
||||
|
||||
/// 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(run) = self.block_list_find_run.take() {
|
||||
for (_, rich_content_view) in self.rich_content_views.iter() {
|
||||
rich_content_view.clear_matches(ctx);
|
||||
}
|
||||
self.block_list_find_run = Some(run.cleared());
|
||||
}
|
||||
ctx.emit(FindEvent::RanFind);
|
||||
}
|
||||
|
||||
/// Updates matches in the active find run for the block at the `block_index`, which is
|
||||
/// presumed to be filtered.
|
||||
///
|
||||
/// Under the hood, this does not result in a new find run, but updates state on the matches
|
||||
/// for the existing find run, which is used to determine if matches should be represented in
|
||||
/// the find bar UI (e.g. match count, focused match index).
|
||||
pub fn update_matches_for_filtered_block(
|
||||
&mut self,
|
||||
block_index: BlockIndex,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
if let (Some(block_list_find_run), Some(filtered_block)) = (
|
||||
self.block_list_find_run.as_mut(),
|
||||
terminal_model.block_list().block_at(block_index),
|
||||
) {
|
||||
let block_sort_direction = InputModeSettings::as_ref(ctx)
|
||||
.input_mode
|
||||
.value()
|
||||
.block_sort_direction();
|
||||
|
||||
block_list_find_run.update_matches_for_filtered_block(
|
||||
filtered_block,
|
||||
block_index,
|
||||
block_sort_direction,
|
||||
);
|
||||
ctx.emit(FindEvent::RanFind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TerminalFindModel {
|
||||
type Event = FindEvent;
|
||||
}
|
||||
|
||||
/// Parameters for a find "run".
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FindOptions {
|
||||
/// The find query, if any.
|
||||
pub query: Option<Arc<String>>,
|
||||
|
||||
/// `true` if the find should be case-sensitive.
|
||||
pub is_case_sensitive: bool,
|
||||
|
||||
/// `true` if the query should be matched as a regex pattern.
|
||||
pub is_regex_enabled: bool,
|
||||
|
||||
/// If `Some()`, the find run only surfaces matches that are in blocks with the provided
|
||||
/// indices. If `None`, the find run surfaces matches across the entire blocklist.
|
||||
///
|
||||
/// This is ignored when applied to alt screen find runs.
|
||||
pub blocks_to_include_in_results: Option<Vec<BlockIndex>>,
|
||||
}
|
||||
|
||||
impl FindOptions {
|
||||
pub fn with_is_case_sensitive(mut self, is_case_sensitive: bool) -> Self {
|
||||
self.is_case_sensitive = is_case_sensitive;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_is_regex_enabled(mut self, is_regex_enabled: bool) -> Self {
|
||||
self.is_regex_enabled = is_regex_enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_query(mut self, query: Option<impl Into<Arc<String>>>) -> Self {
|
||||
self.query = query.map(Into::into);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_blocks_to_include_in_results(
|
||||
mut self,
|
||||
block_indices: Option<impl IntoIterator<Item = BlockIndex>>,
|
||||
) -> Self {
|
||||
self.blocks_to_include_in_results =
|
||||
block_indices.map(|indices| indices.into_iter().collect());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TerminalFindModel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FindModel")
|
||||
.field("terminal_model", &"<TerminalModel>")
|
||||
.field(
|
||||
"rich_content_views",
|
||||
&self.rich_content_views.keys().collect::<Vec<_>>(),
|
||||
)
|
||||
.field("alt_screen_find_run", &self.alt_screen_find_run)
|
||||
.field("block_list_find_run", &self.block_list_find_run)
|
||||
.field("is_find_bar_open", &self.is_find_bar_open)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 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;
|
||||
|
||||
/// Runs a find operation on the blocklist using the given `options` and returns an
|
||||
/// `AltScreenFindRun` with the results.
|
||||
///
|
||||
/// If the given `options` does not contain a query, returns `None`.
|
||||
pub(super) fn run_find_on_alt_screen(
|
||||
options: FindOptions,
|
||||
alt_screen: &AltScreen,
|
||||
) -> AltScreenFindRun {
|
||||
let dfas = options.query.as_ref().and_then(|query| {
|
||||
RegexDFAs::new_with_config(
|
||||
query.as_str(),
|
||||
FindConfig {
|
||||
is_regex_enabled: options.is_regex_enabled,
|
||||
is_case_sensitive: options.is_case_sensitive,
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
|
||||
let matches = dfas
|
||||
.as_ref()
|
||||
.map(|dfas| alt_screen.find(dfas))
|
||||
.unwrap_or_default();
|
||||
let focused_match_index = (!matches.is_empty()).then_some(0);
|
||||
|
||||
AltScreenFindRun {
|
||||
dfas,
|
||||
matches,
|
||||
focused_match_index,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AltScreenFindRun {
|
||||
/// Compiled [`RegexDFAs`] for the find query.
|
||||
///
|
||||
/// If the query in `options` is Some(), this is guaranteed to be `Some()`.
|
||||
dfas: Option<RegexDFAs>,
|
||||
|
||||
/// Matches found in the alt screen.
|
||||
///
|
||||
/// Each match is a range of character indices in the alt screen grid.
|
||||
///
|
||||
/// Matches in this vector are ordered in order of decreasing recency, from "bottom" to "top".
|
||||
/// This ensures that iterating over matches occurs in the order that is expected in the UI.
|
||||
///
|
||||
/// The match at index 0 is the first match to be focused after a fresh find run - this is the
|
||||
/// match closest to the bottom of the alt screen grid.
|
||||
matches: Vec<RangeInclusive<Point>>,
|
||||
|
||||
/// The index of the currently focused match in `matches` vector.
|
||||
focused_match_index: Option<usize>,
|
||||
|
||||
/// The `FindOptions` used to configure the find run.
|
||||
options: FindOptions,
|
||||
}
|
||||
|
||||
impl AltScreenFindRun {
|
||||
pub fn options(&self) -> &FindOptions {
|
||||
&self.options
|
||||
}
|
||||
|
||||
pub fn focused_match_index(&self) -> Option<usize> {
|
||||
self.focused_match_index
|
||||
}
|
||||
|
||||
pub fn focused_match_range(&self) -> Option<&RangeInclusive<Point>> {
|
||||
self.focused_match_index
|
||||
.and_then(|index| self.matches.get(index))
|
||||
}
|
||||
|
||||
/// Returns list of all alt screen matches
|
||||
pub fn matches(&self) -> &[RangeInclusive<Point>] {
|
||||
&self.matches
|
||||
}
|
||||
|
||||
/// Focuses the next match in `matches` based on the given `direction`.
|
||||
pub(super) fn focus_next_match(&mut self, direction: FindDirection) {
|
||||
if let Some(current_focused_match_index) = self.focused_match_index() {
|
||||
let index = current_focused_match_index;
|
||||
let next_match_index = match direction {
|
||||
FindDirection::Up => {
|
||||
if index + 1 < self.matches.len() {
|
||||
index + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
FindDirection::Down => {
|
||||
if index > 0 {
|
||||
index - 1
|
||||
} else {
|
||||
self.matches.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
self.focused_match_index = Some(next_match_index);
|
||||
} else if !self.matches.is_empty() {
|
||||
self.focused_match_index = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reruns the find operation with the same options on the alt screen.
|
||||
pub(super) fn rerun(mut self, alt_screen: &AltScreen) -> Self {
|
||||
let Some(dfas) = self.dfas.as_ref() else {
|
||||
return self;
|
||||
};
|
||||
|
||||
let new_matches = alt_screen.find(dfas);
|
||||
self.matches = new_matches;
|
||||
|
||||
// If there are no more matches, reset the focused index.
|
||||
if self.matches.is_empty() {
|
||||
self.focused_match_index = None;
|
||||
} else if let Some(mut focused_match_index) = self.focused_match_index {
|
||||
// If there are matches and we had one focused before, bring it into range
|
||||
// if it isn't already.
|
||||
while focused_match_index >= self.matches.len() {
|
||||
focused_match_index = focused_match_index.saturating_sub(1);
|
||||
}
|
||||
self.focused_match_index = Some(focused_match_index);
|
||||
} else {
|
||||
// If there are matches but there wasn't an existing focused match,
|
||||
// focus the first match.
|
||||
self.focused_match_index = Some(0);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a cleared version of this run (has no matches but the same options).
|
||||
pub(super) fn cleared(mut self) -> Self {
|
||||
let new_dfas =
|
||||
self.options.query.as_ref().and_then(|query| {
|
||||
match RegexDFAs::new_with_config(
|
||||
query.as_str(),
|
||||
FindConfig {
|
||||
is_regex_enabled: self.options.is_regex_enabled,
|
||||
is_case_sensitive: self.options.is_case_sensitive,
|
||||
},
|
||||
) {
|
||||
Ok(dfas) => Some(dfas),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to construct new RegexDFAs for cleared AltScreenFindRun: {e:?}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
self.dfas = new_dfas;
|
||||
self.matches = vec![];
|
||||
self.focused_match_index = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "alt_screen_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,81 @@
|
||||
use crate::terminal::{
|
||||
find::model::{alt_screen::run_find_on_alt_screen, FindOptions},
|
||||
model::index::Point,
|
||||
TerminalModel,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_alt_screen() {
|
||||
let mut mock_terminal_model = TerminalModel::mock(None, None);
|
||||
mock_terminal_model.set_altscreen_active();
|
||||
mock_terminal_model.process_bytes("foo\r\nbar foo\r\nfoo");
|
||||
|
||||
let run = run_find_on_alt_screen(
|
||||
FindOptions {
|
||||
query: Some("foo".to_owned().into()),
|
||||
is_regex_enabled: false,
|
||||
is_case_sensitive: false,
|
||||
..Default::default()
|
||||
},
|
||||
mock_terminal_model.alt_screen(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
run.matches(),
|
||||
&[
|
||||
Point { row: 2, col: 0 }..=Point { row: 2, col: 2 },
|
||||
Point { row: 1, col: 4 }..=Point { row: 1, col: 6 },
|
||||
Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
]
|
||||
);
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_alt_screen_case_sensitive() {
|
||||
let mut mock_terminal_model = TerminalModel::mock(None, None);
|
||||
mock_terminal_model.set_altscreen_active();
|
||||
mock_terminal_model.process_bytes("foo\r\nbar foo\r\nFoo");
|
||||
|
||||
let run = run_find_on_alt_screen(
|
||||
FindOptions {
|
||||
query: Some("Foo".to_owned().into()),
|
||||
is_regex_enabled: false,
|
||||
is_case_sensitive: true,
|
||||
..Default::default()
|
||||
},
|
||||
mock_terminal_model.alt_screen(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
run.matches(),
|
||||
&[Point { row: 2, col: 0 }..=Point { row: 2, col: 2 },]
|
||||
);
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_alt_screen_regex() {
|
||||
let mut mock_terminal_model = TerminalModel::mock(None, None);
|
||||
mock_terminal_model.set_altscreen_active();
|
||||
mock_terminal_model.process_bytes("aoo\r\nbar foo\r\nboo");
|
||||
|
||||
let run = run_find_on_alt_screen(
|
||||
FindOptions {
|
||||
query: Some("[ab]oo".to_owned().into()),
|
||||
is_regex_enabled: true,
|
||||
is_case_sensitive: false,
|
||||
..Default::default()
|
||||
},
|
||||
mock_terminal_model.alt_screen(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
run.matches(),
|
||||
&[
|
||||
Point { row: 2, col: 0 }..=Point { row: 2, col: 2 },
|
||||
Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
]
|
||||
);
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//! This module implements terminal find functionality for the blocklist.
|
||||
use std::{collections::HashMap, iter, ops::RangeInclusive};
|
||||
|
||||
use itertools::Itertools;
|
||||
use warpui::{units::Lines, 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 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.
|
||||
///
|
||||
/// If the given `options` does not contain a query, short-circuits and returns a find run with no
|
||||
/// matches.
|
||||
pub(super) fn run_find_on_block_list(
|
||||
mut options: FindOptions,
|
||||
block_list: &BlockList,
|
||||
findable_rich_content_views: &HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
ctx: &mut AppContext,
|
||||
) -> BlockListFindRun {
|
||||
let Some(dfas) = options.query.as_ref().and_then(|query| {
|
||||
RegexDFAs::new_with_config(
|
||||
query.as_str(),
|
||||
FindConfig {
|
||||
is_regex_enabled: options.is_regex_enabled,
|
||||
is_case_sensitive: options.is_case_sensitive,
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
}) else {
|
||||
// Clear rich content matches.
|
||||
for rich_content_view in findable_rich_content_views.values() {
|
||||
rich_content_view.clear_matches(ctx);
|
||||
}
|
||||
return BlockListFindRun {
|
||||
options,
|
||||
block_sort_direction,
|
||||
dfas: None,
|
||||
matches: vec![],
|
||||
raw_focused_match_index: None,
|
||||
};
|
||||
};
|
||||
|
||||
let mut matches = vec![];
|
||||
|
||||
// If find in block is enabled, find matches in selected blocks only
|
||||
if let Some(blocks_to_include_in_results) = options.blocks_to_include_in_results.as_mut() {
|
||||
// Sort blocks in descending order so that the most recent block is last, which is the order we expect.
|
||||
blocks_to_include_in_results.sort_by(|i, j| j.cmp(i));
|
||||
|
||||
// Must update matches in order, from first block to last block,
|
||||
// so that matches for the latest blocks come before matches for earlier blocks.
|
||||
// Note that this is true regardless of the blocklist orientation (inverted or not)
|
||||
// In both cases we want the most recent block updated last, which means the sort direction
|
||||
// here should always be MostRecentLast
|
||||
for block_index in blocks_to_include_in_results {
|
||||
let agent_view_state = block_list.agent_view_state();
|
||||
if let Some(block) = block_list
|
||||
.block_at(*block_index)
|
||||
.filter(|block| !block.is_empty(agent_view_state))
|
||||
{
|
||||
if block.height(agent_view_state) == Lines::zero() {
|
||||
// This should not happen in practice, because `blocks_to_include_in_results`
|
||||
// is set by selecting blocks, which are presumably visible.
|
||||
continue;
|
||||
}
|
||||
matches.extend(run_find_on_block(
|
||||
&dfas,
|
||||
block,
|
||||
*block_index,
|
||||
block_sort_direction,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Otherwise, loop through all the blocks in the terminal's blocklist, executing find on each block.
|
||||
run_find_on_sumtree(
|
||||
&options,
|
||||
&dfas,
|
||||
block_list,
|
||||
findable_rich_content_views,
|
||||
block_sort_direction,
|
||||
&mut matches,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
let raw_focused_match_index = (!matches.is_empty()).then_some(0);
|
||||
BlockListFindRun {
|
||||
dfas: Some(dfas),
|
||||
matches,
|
||||
raw_focused_match_index,
|
||||
options,
|
||||
block_sort_direction,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a find operation over blocks yielded by the given `blocks_iter`, appending `BlockListMatches`
|
||||
/// to the `matches` output parameter in the same order as blocks in the `blocks_iter`.
|
||||
fn run_find_on_sumtree(
|
||||
options: &FindOptions,
|
||||
dfas: &RegexDFAs,
|
||||
block_list: &BlockList,
|
||||
rich_content_views: &HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
matches: &mut Vec<BlockListMatch>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let mut cursor = block_list
|
||||
.block_heights()
|
||||
.cursor::<BlockHeight, BlockHeightSummary>();
|
||||
cursor.descend_to_last_item(block_list.block_heights());
|
||||
|
||||
while let Some(item) = cursor.item() {
|
||||
match item {
|
||||
BlockHeightItem::Block(height) if height.into_lines() > Lines::zero() => {
|
||||
let block_index = cursor.start().block_count;
|
||||
if let Some(block) = block_list.block_at(block_index.into()) {
|
||||
matches.extend(run_find_on_block(
|
||||
dfas,
|
||||
block,
|
||||
block_index.into(),
|
||||
block_sort_direction,
|
||||
));
|
||||
}
|
||||
}
|
||||
BlockHeightItem::RichContent(RichContentItem {
|
||||
view_id,
|
||||
last_laid_out_height,
|
||||
..
|
||||
}) if last_laid_out_height.into_lines() > Lines::zero() => {
|
||||
if let Some(findable_view) = rich_content_views.get(view_id) {
|
||||
let mut rich_content_matches = findable_view.run_find(options, ctx);
|
||||
if matches!(block_sort_direction, BlockSortDirection::MostRecentLast) {
|
||||
rich_content_matches.reverse();
|
||||
}
|
||||
|
||||
matches.extend(rich_content_matches.into_iter().map(|match_id| {
|
||||
BlockListMatch::RichContent {
|
||||
match_id,
|
||||
view_id: *view_id,
|
||||
index: cursor.start().total_count.into(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
cursor.prev();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a find operation on the given block and returns the resulting vector of `BlockListMatch`es.
|
||||
fn run_find_on_block(
|
||||
dfas: &RegexDFAs,
|
||||
block: &Block,
|
||||
block_index: BlockIndex,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
) -> Vec<BlockListMatch> {
|
||||
let grid_order = match block_sort_direction {
|
||||
BlockSortDirection::MostRecentFirst => &[GridType::PromptAndCommand, GridType::Output],
|
||||
BlockSortDirection::MostRecentLast => &[GridType::Output, GridType::PromptAndCommand],
|
||||
};
|
||||
|
||||
let mut block_matches = vec![];
|
||||
for grid_type in grid_order.iter() {
|
||||
let mut grid_matches = match grid_type {
|
||||
GridType::PromptAndCommand => block
|
||||
.find_prompt_and_command_grid_matches(dfas)
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range,
|
||||
block_index,
|
||||
is_filtered: false,
|
||||
})
|
||||
})
|
||||
.collect_vec(),
|
||||
GridType::Output => {
|
||||
let mut output_grid_matches = block
|
||||
.find_output_grid_matches(dfas)
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range,
|
||||
block_index,
|
||||
is_filtered: false,
|
||||
})
|
||||
})
|
||||
.collect_vec();
|
||||
update_matches_for_filtered_block(
|
||||
output_grid_matches.iter_mut(),
|
||||
block,
|
||||
block_sort_direction,
|
||||
);
|
||||
output_grid_matches
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if matches!(block_sort_direction, BlockSortDirection::MostRecentFirst) {
|
||||
grid_matches.reverse();
|
||||
}
|
||||
block_matches.extend(grid_matches);
|
||||
}
|
||||
block_matches
|
||||
}
|
||||
|
||||
/// Represents a single find match in a grid-based block in the blocklist..
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockGridMatch {
|
||||
/// The type of grid in which the match was found.
|
||||
pub grid_type: GridType,
|
||||
|
||||
/// The character index range of the match.
|
||||
pub range: RangeInclusive<Point>,
|
||||
|
||||
/// The index of the containing block.
|
||||
pub block_index: BlockIndex,
|
||||
|
||||
/// `true` if the match should be filtered out from displayed results (e.g. if the containing
|
||||
/// row has been filtered out via block filtering).
|
||||
pub is_filtered: bool,
|
||||
}
|
||||
|
||||
/// Represents a single find match in the blocklist.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BlockListMatch {
|
||||
CommandBlock(BlockGridMatch),
|
||||
RichContent {
|
||||
match_id: RichContentMatchId,
|
||||
view_id: EntityId,
|
||||
index: TotalIndex,
|
||||
},
|
||||
}
|
||||
|
||||
impl BlockListMatch {
|
||||
pub fn is_filtered(&self) -> bool {
|
||||
match self {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch { is_filtered, .. }) => *is_filtered,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_type(&self) -> Option<GridType> {
|
||||
match self {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch { grid_type, .. }) => Some(*grid_type),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_block(&self, block_index: BlockIndex) -> bool {
|
||||
match self {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
block_index: match_block_index,
|
||||
..
|
||||
}) => block_index == *match_block_index,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_blockgrid(&self, block_index: BlockIndex, grid_type: GridType) -> bool {
|
||||
match self {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
block_index: match_block_index,
|
||||
grid_type: match_grid_type,
|
||||
..
|
||||
}) => block_index == *match_block_index && grid_type == *match_grid_type,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the result of a find "run" on the blocklist.
|
||||
#[derive(Debug)]
|
||||
pub struct BlockListFindRun {
|
||||
/// Compiled [`RegexDFAs`] for the find query.
|
||||
///
|
||||
/// If the query in `options` is Some(), this is guaranteed to be `Some()`.
|
||||
dfas: Option<RegexDFAs>,
|
||||
|
||||
/// Matches found in the blocklist.
|
||||
///
|
||||
/// Matches in this vector are ordered by block index in order of decreasing recency. Within
|
||||
/// the slice for a given block, matches ordering depends on the `block_sort_direction`. For
|
||||
/// `BlockSortDirection::MostRecentLast`, matches are ordered from "bottom" to "top". For
|
||||
/// `BlockSortDirection::MostRecentFirst`, matches are ordered from "top" to "bottom". This
|
||||
/// ensures that iterating over matches occurs in the order that is expected in the UI.
|
||||
///
|
||||
/// The match at index 0 is the first match to be focused after a fresh find run - for
|
||||
/// pin-to-bottom and waterfall input modes, this is the match closest to the bottom of the
|
||||
/// most recent block. For pin-to-top, this is the match closest to the top of the most recent
|
||||
/// block.
|
||||
matches: Vec<BlockListMatch>,
|
||||
|
||||
/// The index of the currently focused match in the `matches` vector.
|
||||
///
|
||||
/// Note that this may differ from the focused match index displayed in the find bar UI, since
|
||||
/// the number of visible matches may be affected by block filtering. The focused match index
|
||||
/// in the UI thus is relative to visible matches, while this field is relative to all matches.
|
||||
raw_focused_match_index: Option<usize>,
|
||||
|
||||
/// The `FindOptions` used to configure the find run.
|
||||
options: FindOptions,
|
||||
|
||||
/// The block sort direction, reflected in the ordering of intra-block matches in `matches`.
|
||||
block_sort_direction: BlockSortDirection,
|
||||
}
|
||||
|
||||
impl BlockListFindRun {
|
||||
/// Returns the UI focused match index, relative to the list of visible matches.
|
||||
pub fn focused_match_index(&self) -> Option<usize> {
|
||||
self.raw_focused_match_index.map(|focused_index| {
|
||||
focused_index
|
||||
- self.matches[..focused_index]
|
||||
.iter()
|
||||
.fold(0, |count, find_match| {
|
||||
if find_match.is_filtered() {
|
||||
count + 1
|
||||
} else {
|
||||
count
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an iterator over visible `BlockListMatch`es.
|
||||
pub fn matches(&self) -> impl Iterator<Item = &BlockListMatch> {
|
||||
self.matches.iter().filter(|m| !m.is_filtered())
|
||||
}
|
||||
|
||||
/// Returns an iterator over matches for the given block index and grid type, in "ascending"
|
||||
/// order.
|
||||
///
|
||||
/// This logic relies on the ordering of `self.matches` explained in the field declaration.
|
||||
pub fn matches_for_block_grid(
|
||||
&self,
|
||||
block_index: BlockIndex,
|
||||
grid_type: GridType,
|
||||
) -> Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_> {
|
||||
let Some(start_index) = self
|
||||
.matches
|
||||
.iter()
|
||||
.position(|m| m.matches_blockgrid(block_index, grid_type))
|
||||
else {
|
||||
return Box::new(iter::empty());
|
||||
};
|
||||
let match_slice = if let Some(relative_end_index) = self.matches[start_index..]
|
||||
.iter()
|
||||
.position(|m| !m.matches_blockgrid(block_index, grid_type))
|
||||
{
|
||||
&self.matches[start_index..(start_index + relative_end_index)]
|
||||
} else {
|
||||
&self.matches[start_index..]
|
||||
};
|
||||
match self.block_sort_direction {
|
||||
BlockSortDirection::MostRecentLast => Box::new(
|
||||
match_slice
|
||||
.iter()
|
||||
.filter_map(|m| match m {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range, is_filtered, ..
|
||||
}) => {
|
||||
if !is_filtered {
|
||||
Some(range)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.rev(),
|
||||
),
|
||||
BlockSortDirection::MostRecentFirst => {
|
||||
Box::new(match_slice.iter().filter_map(|m| match m {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range, is_filtered, ..
|
||||
}) => {
|
||||
if !is_filtered {
|
||||
Some(range)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focused_match(&self) -> Option<&BlockListMatch> {
|
||||
self.raw_focused_match_index
|
||||
.and_then(|i| self.matches.get(i))
|
||||
}
|
||||
|
||||
pub fn options(&self) -> &FindOptions {
|
||||
&self.options
|
||||
}
|
||||
|
||||
/// Focuses the next match in `matches` based on the given `direction` and `block_sort_direction`.
|
||||
pub(super) fn focus_next_match(
|
||||
&mut self,
|
||||
direction: FindDirection,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
) {
|
||||
let new_focused_index = match (self.raw_focused_match_index, self.matches.is_empty()) {
|
||||
(_, true) => None,
|
||||
(Some(mut current_index), false) => {
|
||||
let mut new_focused_index = None;
|
||||
for _ in 0..self.matches.len() {
|
||||
current_index = match (direction, block_sort_direction) {
|
||||
(FindDirection::Up, BlockSortDirection::MostRecentLast)
|
||||
| (FindDirection::Down, BlockSortDirection::MostRecentFirst) => {
|
||||
if current_index + 1 < self.matches.len() {
|
||||
current_index + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
(FindDirection::Down, BlockSortDirection::MostRecentLast)
|
||||
| (FindDirection::Up, BlockSortDirection::MostRecentFirst) => {
|
||||
if current_index > 0 {
|
||||
current_index - 1
|
||||
} else {
|
||||
self.matches.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
if !self.matches[current_index].is_filtered() {
|
||||
new_focused_index = Some(current_index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
new_focused_index
|
||||
}
|
||||
(None, false) => Some(0),
|
||||
};
|
||||
self.raw_focused_match_index = new_focused_index;
|
||||
}
|
||||
|
||||
/// Reruns the find operation on the block at the given index, updates the matches with the new results.
|
||||
///
|
||||
/// Note that matches for a given block are expected to appear in a contiguous slice (per the
|
||||
/// expected ordering of `self.matches`).
|
||||
pub(super) fn rerun_on_block(
|
||||
mut self,
|
||||
block: &Block,
|
||||
block_index: BlockIndex,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
) -> Self {
|
||||
let Some(dfas) = self.dfas.as_ref() else {
|
||||
return self;
|
||||
};
|
||||
|
||||
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);
|
||||
if let Some(start_index) = old_block_matches_start_index {
|
||||
let end_index = old_block_matches_start_index
|
||||
.and_then(|i| {
|
||||
self.matches[(i + 1)..]
|
||||
.iter()
|
||||
.position(|find_match| !find_match.matches_block(block_index))
|
||||
.map(|j| i + j + 1)
|
||||
})
|
||||
.unwrap_or(self.matches.len());
|
||||
|
||||
// Splice in the new matches where the old block matches used to exist.
|
||||
self.matches.splice(start_index..end_index, new_matches);
|
||||
} else {
|
||||
new_matches.append(&mut self.matches);
|
||||
self.matches = new_matches;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
self.raw_focused_match_index = Some(focused_match_index);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn update_matches_for_filtered_block(
|
||||
&mut self,
|
||||
block: &Block,
|
||||
block_index: BlockIndex,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
) {
|
||||
update_matches_for_filtered_block(
|
||||
self.matches.iter_mut().filter(|find_match| {
|
||||
find_match.matches_block(block_index)
|
||||
&& matches!(find_match.grid_type(), Some(GridType::Output))
|
||||
}),
|
||||
block,
|
||||
block_sort_direction,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn cleared(mut self) -> Self {
|
||||
let new_dfas =
|
||||
self.options.query.as_ref().and_then(|query| {
|
||||
match RegexDFAs::new_with_config(
|
||||
query.as_str(),
|
||||
FindConfig {
|
||||
is_regex_enabled: self.options.is_regex_enabled,
|
||||
is_case_sensitive: self.options.is_case_sensitive,
|
||||
},
|
||||
) {
|
||||
Ok(dfas) => Some(dfas),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to construct new RegexDFAs for cleared BlockListFindRun: {e:?}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
self.dfas = new_dfas;
|
||||
self.matches = vec![];
|
||||
self.raw_focused_match_index = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn update_matches_for_filtered_block<'a>(
|
||||
mut matches: impl Iterator<Item = &'a mut BlockListMatch>,
|
||||
block: &Block,
|
||||
block_sort_direction: BlockSortDirection,
|
||||
) {
|
||||
let Some(displayed_rows) = block.displayed_output_row_ranges() else {
|
||||
matches.for_each(|find_match| {
|
||||
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
ref mut is_filtered,
|
||||
..
|
||||
}) = find_match
|
||||
{
|
||||
*is_filtered = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
match block_sort_direction {
|
||||
BlockSortDirection::MostRecentLast => {
|
||||
let mut current_find_match = matches.next();
|
||||
|
||||
let mut displayed_row_ranges = displayed_rows.rev();
|
||||
let mut current_row_range = displayed_row_ranges.next();
|
||||
|
||||
loop {
|
||||
match (current_find_match.take(), current_row_range.take()) {
|
||||
(Some(find_match), Some(row_range)) => {
|
||||
match find_match {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range,
|
||||
is_filtered,
|
||||
..
|
||||
}) if range.end().row > *row_range.end() => {
|
||||
*is_filtered = true;
|
||||
current_find_match = matches.next();
|
||||
current_row_range = Some(row_range);
|
||||
}
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range,
|
||||
is_filtered,
|
||||
..
|
||||
}) if range.start().row >= *row_range.start() => {
|
||||
*is_filtered = false;
|
||||
current_find_match = matches.next();
|
||||
current_row_range = Some(row_range);
|
||||
}
|
||||
_ => {
|
||||
current_find_match = Some(find_match);
|
||||
current_row_range = displayed_row_ranges.next();
|
||||
}
|
||||
};
|
||||
}
|
||||
(Some(find_match), None) => {
|
||||
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
is_filtered, ..
|
||||
}) = find_match
|
||||
{
|
||||
*is_filtered = true;
|
||||
}
|
||||
current_find_match = matches.next();
|
||||
}
|
||||
(None, _) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockSortDirection::MostRecentFirst => {
|
||||
let mut current_find_match = matches.next();
|
||||
|
||||
let mut displayed_row_ranges = displayed_rows;
|
||||
let mut current_row_range = displayed_row_ranges.next();
|
||||
|
||||
loop {
|
||||
match (current_find_match.take(), current_row_range.take()) {
|
||||
(Some(find_match), Some(row_range)) => {
|
||||
match find_match {
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range,
|
||||
is_filtered,
|
||||
..
|
||||
}) if range.start().row < *row_range.start() => {
|
||||
*is_filtered = true;
|
||||
current_find_match = matches.next();
|
||||
}
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
range,
|
||||
is_filtered,
|
||||
..
|
||||
}) if range.end().row <= *row_range.end() => {
|
||||
*is_filtered = false;
|
||||
current_find_match = matches.next();
|
||||
}
|
||||
_ => {
|
||||
current_find_match = Some(find_match);
|
||||
current_row_range = displayed_row_ranges.next();
|
||||
}
|
||||
};
|
||||
}
|
||||
(Some(find_match), None) => {
|
||||
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
is_filtered, ..
|
||||
}) = find_match
|
||||
{
|
||||
*is_filtered = true;
|
||||
}
|
||||
current_find_match = matches.next();
|
||||
}
|
||||
(None, _) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "block_list_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,355 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use itertools::Itertools;
|
||||
use warpui::App;
|
||||
|
||||
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};
|
||||
|
||||
impl BlockListFindRun {
|
||||
fn all_matches(&self) -> &[BlockListMatch] {
|
||||
&self.matches[..]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_block_list() {
|
||||
App::test((), |mut app| async move {
|
||||
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 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,
|
||||
)
|
||||
});
|
||||
|
||||
// Matches should be sorted by recency at the block level, then "bottom to top" within each
|
||||
// block.
|
||||
assert_eq!(
|
||||
run.matches().collect_vec(),
|
||||
vec![
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_block_list_pin_to_top() {
|
||||
App::test((), |mut app| async move {
|
||||
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 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::MostRecentFirst,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Matches should be sorted by recency at the block level, then "top to bottom" within each
|
||||
// block.
|
||||
assert_eq!(
|
||||
run.matches().collect_vec(),
|
||||
vec![
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_block_list_case_sensitive() {
|
||||
App::test((), |mut app| async move {
|
||||
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 run = app.update(|ctx| {
|
||||
run_find_on_block_list(
|
||||
FindOptions {
|
||||
query: Some("Bar".to_owned().into()),
|
||||
is_regex_enabled: false,
|
||||
is_case_sensitive: true,
|
||||
..Default::default()
|
||||
},
|
||||
mock_terminal_model.block_list(),
|
||||
&HashMap::new(),
|
||||
BlockSortDirection::MostRecentLast,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Matches should be sorted by recency at the block level, then "bottom to top" within each
|
||||
// block.
|
||||
assert_eq!(
|
||||
run.matches().collect_vec(),
|
||||
vec![
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_block_list_regex_enabled() {
|
||||
App::test((), |mut app| async move {
|
||||
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 run = app.update(|ctx| {
|
||||
run_find_on_block_list(
|
||||
FindOptions {
|
||||
query: Some("ba[rz]".to_owned().into()),
|
||||
is_regex_enabled: true,
|
||||
is_case_sensitive: false,
|
||||
..Default::default()
|
||||
},
|
||||
mock_terminal_model.block_list(),
|
||||
&HashMap::new(),
|
||||
BlockSortDirection::MostRecentLast,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
// Matches should be sorted by recency at the block level, then "bottom to top" within each
|
||||
// block.
|
||||
assert_eq!(
|
||||
run.matches().collect_vec(),
|
||||
vec![
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 4 }..=Point { row: 0, col: 6 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
assert_eq!(run.focused_match_index(), Some(0));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_find_on_block_list_with_filtered_block() {
|
||||
App::test((), |mut app| async move {
|
||||
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 filter", "bar baz\r\nbar bat\r\nbar baz\r\n");
|
||||
|
||||
// Filter the second block to only show lines containing "bat".
|
||||
mock_terminal_model
|
||||
.update_filter_on_block(2.into(), BlockFilterQuery::new_for_test("bat".to_owned()));
|
||||
|
||||
let 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,
|
||||
)
|
||||
});
|
||||
|
||||
// Matches should be sorted by recency at the block level, then "bottom to top" within each
|
||||
// block.
|
||||
//
|
||||
// The first and third rows of the most recent block contain baz, and should be filtered.
|
||||
assert_eq!(
|
||||
run.matches().collect_vec(),
|
||||
vec![
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
&BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
// Check `all_matches` so we can ensure the filtered matches exist, but were filtered.
|
||||
assert_eq!(
|
||||
run.all_matches(),
|
||||
&[
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 2, col: 0 }..=Point { row: 2, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: true,
|
||||
}),
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: true,
|
||||
}),
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
|
||||
block_index: 2.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::Output,
|
||||
range: Point { row: 1, col: 0 }..=Point { row: 1, col: 2 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
BlockListMatch::CommandBlock(BlockGridMatch {
|
||||
grid_type: GridType::PromptAndCommand,
|
||||
range: Point { row: 0, col: 3 }..=Point { row: 0, col: 5 },
|
||||
block_index: 1.into(),
|
||||
is_filtered: false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Traits and abstractions supporting the find operation across rich content blocks.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use warpui::{AppContext, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::FindOptions;
|
||||
|
||||
/// Unique ID for a find match in a rich content view.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RichContentMatchId(usize);
|
||||
|
||||
impl Default for RichContentMatchId {
|
||||
fn default() -> Self {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
Self(COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to be implemented by blocklist rich content view to support find operations.
|
||||
pub trait FindableRichContentView: View {
|
||||
/// Runs a find operation configured with the given `options` on the view and returns
|
||||
/// a list of match IDs corresponding to found matches.
|
||||
///
|
||||
/// The view is responsible for actually storing matches (in whatever representation makes
|
||||
/// sense for the given view) and maintaining a mapping between the returned match IDs and the
|
||||
/// internal matches.
|
||||
///
|
||||
/// As such, the view is also responsible for rendering the appropriate UI for highlighted
|
||||
/// matches.
|
||||
fn run_find(
|
||||
&mut self,
|
||||
options: &FindOptions,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<RichContentMatchId>;
|
||||
|
||||
/// Clears cached matches stored in a previous `run_find` call, if necessary.
|
||||
fn clear_matches(&mut self, ctx: &mut ViewContext<Self>);
|
||||
}
|
||||
|
||||
/// Wrapper trait around `RichContentView` that enables storing a homogenous collection of
|
||||
/// `RichContentView` implementations (via their corresponding `ViewHandle`s).
|
||||
///
|
||||
/// Simply directs each method call to the corresponding `FindableRichContentView` call.
|
||||
///
|
||||
/// 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 {
|
||||
fn run_find(&self, options: &FindOptions, ctx: &mut AppContext) -> Vec<RichContentMatchId>;
|
||||
|
||||
fn clear_matches(&self, ctx: &mut AppContext);
|
||||
}
|
||||
|
||||
/// Blanket implementation of `FindableRichContentHandle` for any handles of view type that
|
||||
/// implements `FindableRichContentView`.
|
||||
impl<F> FindableRichContentHandle for ViewHandle<F>
|
||||
where
|
||||
F: FindableRichContentView,
|
||||
{
|
||||
fn run_find(&self, options: &FindOptions, ctx: &mut AppContext) -> Vec<RichContentMatchId> {
|
||||
self.update(ctx, |me, ctx| me.run_find(options, ctx))
|
||||
}
|
||||
|
||||
fn clear_matches(&self, ctx: &mut AppContext) {
|
||||
self.update(ctx, |me, ctx| me.clear_matches(ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Exports helper test-only methods for use in unit and integration tests.
|
||||
use itertools::Itertools;
|
||||
|
||||
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
|
||||
.as_ref()
|
||||
.map(|run| {
|
||||
run.matches()
|
||||
.filter(|find_match| !find_match.is_filtered())
|
||||
.collect_vec()
|
||||
.len()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockListFindRun {
|
||||
pub fn matches_for_block(&self, index: BlockIndex) -> impl Iterator<Item = &BlockListMatch> {
|
||||
self.matches()
|
||||
.filter(move |find_match| find_match.matches_block(index))
|
||||
}
|
||||
|
||||
pub fn focused_match_block_index(&self) -> Option<BlockIndex> {
|
||||
self.focused_match_index().and_then(|index| {
|
||||
self.matches().nth(index).and_then(|m| {
|
||||
if let BlockListMatch::CommandBlock(block_match) = m {
|
||||
Some(block_match.block_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{banner::BannerState, resource_center::Tip};
|
||||
use warp_core::settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(GeneralSettings, settings: [
|
||||
show_warning_before_quitting: ShowWarningBeforeQuitting {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.show_warning_before_quitting",
|
||||
description: "Whether to show a warning dialog before quitting Warp.",
|
||||
},
|
||||
quit_on_last_window_closed: QuitOnLastWindowClosed {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::MAC,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.quit_on_last_window_closed",
|
||||
description: "Whether to quit Warp when the last window is closed.",
|
||||
},
|
||||
restore_session: RestoreSession {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.restore_session",
|
||||
description: "Whether to restore the previous session when Warp starts up.",
|
||||
},
|
||||
add_app_as_login_item: LoginItem {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::OR(
|
||||
Box::new(SupportedPlatforms::MAC),
|
||||
Box::new(SupportedPlatforms::WINDOWS),
|
||||
),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "general.login_item",
|
||||
description: "Whether to launch Warp automatically when you log in.",
|
||||
},
|
||||
// Records whether the app has been added as a login item.
|
||||
// If it has, we don't try to add it again unless the user explicitly
|
||||
// retoggles the setting. This is to allow a user to remove the login item
|
||||
// directly from their OS's startup UI and not have it re-added when they
|
||||
// next start Warp.
|
||||
app_added_as_login_item: AppAddedAsLoginItem {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::OR(
|
||||
Box::new(SupportedPlatforms::MAC),
|
||||
Box::new(SupportedPlatforms::WINDOWS),
|
||||
),
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
link_tooltip: LinkTooltip {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.link_tooltip",
|
||||
description: "Whether to show a tooltip when hovering over links.",
|
||||
},
|
||||
welcome_tips_features_used: WelcomeTipsFeaturesUsed {
|
||||
type: HashSet<Tip>,
|
||||
default: HashSet::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
welcome_tips_skipped_or_completed: WelcomeTipsCompleted {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
agent_mode_onboarding_block_shown: AgentModeOnboardingBlockShown {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
telemetry_banner_dismissed: TelemetryBannerDismissed {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
user_default_shell_unsupported_banner_state: UserDefaultShellUnsupportedBannerState {
|
||||
type: BannerState,
|
||||
default: BannerState::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
open_in_warp_banner_dismissed_for_markdown: OpenInWarpBannerDismissedMarkdown {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
open_in_warp_banner_dismissed_for_code_and_text: OpenInWarpBannerDismissedCode {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
did_non_anonymous_user_log_in: DidNonAnonymousUserLogIn {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
free_tier_limit_hit_modal_dismissed: FreeTierLimitHitModalDismissed {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
build_plan_migration_modal_dismissed: BuildPlanMigrationModalDismissed {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
// One-time flag tracking whether the OpenWarp launch modal has already been
|
||||
// shown to the user. Not user-visible; modeled as a setting so it's only
|
||||
// shown once per user regardless of the number of devices they use.
|
||||
did_check_to_trigger_openwarp_launch_modal: DidShowOpenWarpLaunchModal {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: true,
|
||||
},
|
||||
anonymous_user_ai_sign_up_banner_shown: AnonymousUserAISignUpBannerShown {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
auto_open_code_review_pane_on_first_agent_change: AutoOpenCodeReviewPaneOnFirstAgentChange {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.auto_open_code_review_pane_on_first_agent_change",
|
||||
description: "Whether to automatically open the code review pane when the agent makes its first change.",
|
||||
},
|
||||
bonus_grants_shown: BonusGrantsShown {
|
||||
type: HashSet<String>,
|
||||
default: HashSet::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
//! This module defines CellGlyphCache, a struct which manages the caching of glyph values for cells
|
||||
//! when rendering Grids within Warp.
|
||||
use warpui::elements::DEFAULT_LINE_HEIGHT_RATIO;
|
||||
|
||||
use warpui::fonts::{Cache as FontCache, FamilyId, FontId, GlyphId, Properties};
|
||||
use warpui::platform::LineStyle;
|
||||
use warpui::text_layout::{StyleAndFont, DEFAULT_TOP_BOTTOM_RATIO};
|
||||
use warpui::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
|
||||
/// performance reasons (avoid allocating strings when we don't need to!).
|
||||
#[derive(Default)]
|
||||
pub struct CellGlyphCache {
|
||||
glyph_cache: HashMap<(char, FontId), Option<(GlyphId, FontId)>>,
|
||||
string_cache: HashMap<(String, FontId), Option<(GlyphId, FontId)>>,
|
||||
}
|
||||
|
||||
impl CellGlyphCache {
|
||||
pub(super) fn glyph_for_char(
|
||||
&mut self,
|
||||
char: char,
|
||||
font_id: FontId,
|
||||
font_cache: &FontCache,
|
||||
) -> Option<(GlyphId, FontId)> {
|
||||
*self
|
||||
.glyph_cache
|
||||
.entry((char, font_id))
|
||||
.or_insert_with(|| font_cache.glyph_for_char(font_id, char, true))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn glyph_for_string(
|
||||
&mut self,
|
||||
string: &str,
|
||||
font_id: FontId,
|
||||
font_cache: &FontCache,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
properties: Properties,
|
||||
ctx: &mut PaintContext,
|
||||
) -> Option<(GlyphId, FontId)> {
|
||||
let glyph = *self
|
||||
.string_cache
|
||||
.entry((string.to_owned(), font_id))
|
||||
.or_insert_with(|| {
|
||||
// Calculate the length of total characters in the string.
|
||||
let run_length_chars = string.chars().count();
|
||||
let line = ctx.text_layout_cache.layout_line(
|
||||
string,
|
||||
LineStyle {
|
||||
font_size,
|
||||
// Note that we DO NOT paint the `Line` in this particular instance. As such,
|
||||
// the line height ratio and baseline ratio are both NOT used. Hence, we arbitrarily
|
||||
// set them to the default values.
|
||||
line_height_ratio: DEFAULT_LINE_HEIGHT_RATIO,
|
||||
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
&[(
|
||||
(0..run_length_chars),
|
||||
StyleAndFont {
|
||||
font_family,
|
||||
properties,
|
||||
style: Default::default(),
|
||||
},
|
||||
)],
|
||||
f32::MAX,
|
||||
Default::default(),
|
||||
&font_cache.text_layout_system(),
|
||||
);
|
||||
let run = line.runs.first()?;
|
||||
if run.glyphs.len() > 1 {
|
||||
// If we have more than one glyph, something has gone wrong.
|
||||
return None;
|
||||
}
|
||||
run.glyphs.first().map(|glyph| (glyph.id, run.font_id))
|
||||
});
|
||||
|
||||
glyph.or_else(|| {
|
||||
#[cfg(debug_assertions)]
|
||||
log::warn!("Falling back to glyph for first character of string, could not get glyph for entire string: {string:?}");
|
||||
let first_char = string.chars().next()?;
|
||||
let glyph = self.glyph_for_char(first_char, font_id, font_cache);
|
||||
// Make sure we update the cache with the fallback, so we don't
|
||||
// recompute it again.
|
||||
self.string_cache.insert((string.to_owned(), font_id), glyph);
|
||||
glyph
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) struct Secret {
|
||||
pub(super) hovered: bool,
|
||||
pub(super) is_obfuscated: bool,
|
||||
}
|
||||
|
||||
/// Determines whether a match is focused.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub(super) enum IsFocused {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq)]
|
||||
pub(super) struct CellType {
|
||||
pub(super) is_find_match: Option<IsFocused>,
|
||||
pub(super) is_url: bool,
|
||||
pub(super) secret: Option<Secret>,
|
||||
pub(super) is_filter_match: bool,
|
||||
pub(super) is_marked_text_char: bool,
|
||||
}
|
||||
|
||||
impl CellType {
|
||||
pub(super) fn marked_text_char() -> Self {
|
||||
Self {
|
||||
is_marked_text_char: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_find_match(&self) -> bool {
|
||||
self.is_find_match.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn is_focused_find_match(&self) -> bool {
|
||||
self.is_find_match
|
||||
.map_or_else(|| false, |is_focused| matches!(is_focused, IsFocused::Yes))
|
||||
}
|
||||
|
||||
pub(super) fn is_unfocused_find_match(&self) -> bool {
|
||||
self.is_find_match
|
||||
.map_or_else(|| false, |is_focused| matches!(is_focused, IsFocused::No))
|
||||
}
|
||||
|
||||
pub(super) fn is_filter_match(&self) -> bool {
|
||||
self.is_filter_match
|
||||
}
|
||||
|
||||
pub(super) fn is_url(&self) -> bool {
|
||||
self.is_url
|
||||
}
|
||||
|
||||
pub(super) fn is_secret(&self) -> bool {
|
||||
self.secret.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn is_hovered_secret(&self) -> bool {
|
||||
self.secret
|
||||
.as_ref()
|
||||
.map_or_else(|| false, |secret| secret.hovered)
|
||||
}
|
||||
|
||||
pub(super) fn is_unhovered_secret(&self) -> bool {
|
||||
self.secret
|
||||
.as_ref()
|
||||
.map_or_else(|| false, |secret| !secret.hovered)
|
||||
}
|
||||
|
||||
/// Used to check if a CellType is equivalent to the default (i.e. no matches, urls, secrets, etc).
|
||||
pub(super) fn is_default(&self) -> bool {
|
||||
&Self::default() == self
|
||||
}
|
||||
|
||||
pub(super) fn is_marked_text_char(&self) -> bool {
|
||||
self.is_marked_text_char
|
||||
}
|
||||
|
||||
/// Calculate the foreground color for a cell. Does not set alpha value.
|
||||
pub(super) fn foreground_color(
|
||||
&self,
|
||||
cell: &Cell,
|
||||
colors: &color::List,
|
||||
override_colors: &color::OverrideList,
|
||||
obfuscate_mode: ObfuscateSecrets,
|
||||
) -> ColorU {
|
||||
let is_unhovered_secret = self.is_unhovered_secret();
|
||||
|
||||
if self.is_filter_match() {
|
||||
*BLOCK_FILTER_MATCH_COLOR
|
||||
} else if self.is_url() || self.is_hovered_secret() {
|
||||
*URL_COLOR
|
||||
} else if matches!(obfuscate_mode, ObfuscateSecrets::Strikethrough) && is_unhovered_secret {
|
||||
warpui::color::ColorU::new(128, 128, 128, 255)
|
||||
} else if self.is_default()
|
||||
|| self.is_marked_text_char()
|
||||
|| is_unhovered_secret
|
||||
|| matches!(obfuscate_mode, ObfuscateSecrets::AlwaysShow)
|
||||
{
|
||||
compute_fg_rgb(colors, override_colors, cell.fg, cell.flags)
|
||||
} else {
|
||||
ColorU::black()
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the background color (including the alpha) for a cell.
|
||||
pub(super) fn background_color(
|
||||
&self,
|
||||
cell: &Cell,
|
||||
colors: &color::List,
|
||||
override_colors: &color::OverrideList,
|
||||
) -> ColorU {
|
||||
let mut bg_color =
|
||||
if self.is_unfocused_find_match() && !self.is_hovered_secret() && !self.is_url() {
|
||||
*MATCH_COLOR
|
||||
} else if self.is_focused_find_match() && !self.is_hovered_secret() && !self.is_url() {
|
||||
*FOCUSED_MATCH_COLOR
|
||||
} else {
|
||||
compute_bg_rgb(colors, override_colors, cell.bg)
|
||||
};
|
||||
let bg_alpha = if cell.flags.contains(Flags::INVERSE) {
|
||||
OPAQUE
|
||||
} else if self.is_default()
|
||||
|| self.is_url()
|
||||
|| (self.is_filter_match() && !self.is_find_match())
|
||||
|| (self.is_secret() && !self.is_find_match())
|
||||
|| self.is_hovered_secret()
|
||||
{
|
||||
if cell.bg == Color::Named(NamedColor::Background) {
|
||||
// If the background of the cell is the same as the terminal background, treat it as
|
||||
// an alpha value of 0.
|
||||
0
|
||||
} else {
|
||||
OPAQUE
|
||||
}
|
||||
} else {
|
||||
OPAQUE
|
||||
};
|
||||
bg_color.a = bg_alpha;
|
||||
bg_color
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the RGB color from a cell's foreground color.
|
||||
fn compute_fg_rgb(
|
||||
colors: &color::List,
|
||||
override_colors: &color::OverrideList,
|
||||
fg: Color,
|
||||
flags: Flags,
|
||||
) -> ColorU {
|
||||
match fg {
|
||||
Color::Spec(rgb) => match flags & Flags::DIM {
|
||||
Flags::DIM => crate::terminal::color::dim(rgb),
|
||||
_ => rgb,
|
||||
},
|
||||
Color::Named(ansi) => {
|
||||
match flags & Flags::DIM_BOLD {
|
||||
// If no bright foreground is set, treat it like the BOLD flag doesn't exist.
|
||||
Flags::DIM_BOLD if ansi == NamedColor::Foreground => {
|
||||
get_override_color(colors, override_colors, color_index::DIM_FOREGROUND)
|
||||
}
|
||||
// Cell is marked as dim and not bold.
|
||||
Flags::DIM => {
|
||||
get_override_color(colors, override_colors, ansi.to_dim().into_color_index())
|
||||
}
|
||||
// None of the above, keep original color..
|
||||
_ => get_override_color(colors, override_colors, ansi.into_color_index()),
|
||||
}
|
||||
}
|
||||
Color::Indexed(idx) => {
|
||||
let idx = match (flags & Flags::DIM_BOLD, idx) {
|
||||
(Flags::DIM, 8..=15) => idx as usize - 8,
|
||||
(Flags::DIM, 0..=7) => color_index::DIM_BLACK + idx as usize,
|
||||
_ => idx as usize,
|
||||
};
|
||||
|
||||
get_override_color(colors, override_colors, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the RGB color from a cell's background color.
|
||||
fn compute_bg_rgb(
|
||||
colors: &color::List,
|
||||
override_colors: &color::OverrideList,
|
||||
bg: Color,
|
||||
) -> ColorU {
|
||||
match bg {
|
||||
Color::Spec(rgb) => rgb,
|
||||
Color::Named(ansi) => get_override_color(colors, override_colors, ansi.into_color_index()),
|
||||
Color::Indexed(idx) => get_override_color(colors, override_colors, idx as usize),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_override_color(
|
||||
colors: &color::List,
|
||||
override_colors: &color::OverrideList,
|
||||
index: usize,
|
||||
) -> ColorU {
|
||||
override_colors[index].unwrap_or(colors[index])
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::fonts::Cache as FontCache;
|
||||
use warpui::units::{IntoLines, Lines, Pixels};
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// TODO(CORE-2002): Make test non-Mac specific by switching to using bundled Roboto font.
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
not(target_os = "macos"),
|
||||
ignore = "Assumes existence of Arial font, which is only guaranteed on macOS"
|
||||
)]
|
||||
fn test_calculate_grid_baseline_position() {
|
||||
let font_db = warpui::platform::test::FontDB::new();
|
||||
let mut font_cache = FontCache::new(Box::new(font_db));
|
||||
// Note we've restricted this unit test to Mac, so we expect Arial to exist.
|
||||
let arial = font_cache
|
||||
.load_system_font("Arial")
|
||||
.expect("Arial must exist");
|
||||
let baseline_position = calculate_grid_baseline_position(
|
||||
&font_cache,
|
||||
arial,
|
||||
16., /* font_size */
|
||||
1.2, /* line_height_ratio */
|
||||
19., /* cell_size_y */
|
||||
);
|
||||
assert_eq!(baseline_position, vec2f(0., 15.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_match_same_row_matches() {
|
||||
let match_1 = Point::new(0, 0)..=Point::new(0, 4);
|
||||
let match_2 = Point::new(1, 0)..=Point::new(1, 4);
|
||||
let matches = [match_1.clone(), match_2.clone()];
|
||||
let mut filter_match_iter = matches.iter();
|
||||
|
||||
let mut current_match = None;
|
||||
|
||||
// The first match should return for points (0,0) through (0,4).
|
||||
for i in 0..=4 {
|
||||
current_match =
|
||||
active_or_next_match(&mut filter_match_iter, current_match, &Point::new(0, i));
|
||||
assert_eq!(current_match, Some(&match_1));
|
||||
}
|
||||
|
||||
// The second match should return for points (1,0) through (1,4).
|
||||
for i in 0..=4 {
|
||||
current_match =
|
||||
active_or_next_match(&mut filter_match_iter, current_match, &Point::new(1, i));
|
||||
assert_eq!(current_match, Some(&match_2));
|
||||
}
|
||||
|
||||
// There should be no more matches left after we advance to point (2,0).
|
||||
current_match = active_or_next_match(&mut filter_match_iter, current_match, &Point::new(2, 0));
|
||||
assert_eq!(current_match, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_match_multi_row_matches() {
|
||||
let match_1 = Point::new(0, 0)..=Point::new(1, 2);
|
||||
let match_2 = Point::new(2, 0)..=Point::new(3, 2);
|
||||
let matches = [match_1.clone(), match_2.clone()];
|
||||
let mut match_iter = matches.iter();
|
||||
|
||||
let mut current_match = None;
|
||||
|
||||
// The first match should be returned for all points from (0,0) to (1,2).
|
||||
let points_1 = [
|
||||
Point::new(0, 0),
|
||||
Point::new(0, 1),
|
||||
Point::new(0, 2),
|
||||
Point::new(1, 0),
|
||||
Point::new(1, 1),
|
||||
Point::new(1, 2),
|
||||
];
|
||||
for point in points_1.iter() {
|
||||
current_match = active_or_next_match(&mut match_iter, current_match, point);
|
||||
assert_eq!(current_match, Some(&match_1));
|
||||
}
|
||||
|
||||
// The second match should be returned for all points from (2,0) to (3,2).
|
||||
let points_2 = [
|
||||
Point::new(2, 0),
|
||||
Point::new(2, 1),
|
||||
Point::new(2, 2),
|
||||
Point::new(3, 0),
|
||||
Point::new(3, 1),
|
||||
Point::new(3, 2),
|
||||
];
|
||||
for point in points_2.iter() {
|
||||
current_match = active_or_next_match(&mut match_iter, current_match, point);
|
||||
assert_eq!(current_match, Some(&match_2));
|
||||
}
|
||||
|
||||
// There should be no more matches left after we advance to point (4,0).
|
||||
current_match = active_or_next_match(&mut match_iter, current_match, &Point::new(4, 0));
|
||||
assert_eq!(current_match, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_or_next_match_point_before_next_match() {
|
||||
let match_1 = Point::new(1, 0)..=Point::new(1, 4);
|
||||
let match_2 = Point::new(3, 0)..=Point::new(3, 4);
|
||||
let matches = [match_1.clone(), match_2.clone()];
|
||||
let mut match_iter = matches.iter();
|
||||
|
||||
// The match for (0,0) should be the first match.
|
||||
let mut current_match = active_or_next_match(&mut match_iter, None, &Point::new(0, 0));
|
||||
assert_eq!(current_match, Some(&match_1));
|
||||
|
||||
// The match for (2,0) should be the second match.
|
||||
current_match = active_or_next_match(&mut match_iter, current_match, &Point::new(2, 0));
|
||||
assert_eq!(current_match, Some(&match_2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_background_bounds() {
|
||||
let origin = vec2f(100., 100.);
|
||||
let cell_size = vec2f(2., 4.);
|
||||
let max_columns = 150;
|
||||
let create_cached = |start_row: usize, start_col: usize, end_row: usize, end_col: usize| {
|
||||
CachedBackgroundColor {
|
||||
start: SelectionPoint {
|
||||
row: start_row.into_lines(),
|
||||
col: start_col,
|
||||
},
|
||||
end: SelectionPoint {
|
||||
row: end_row.into_lines(),
|
||||
col: end_col,
|
||||
},
|
||||
background_color: Default::default(),
|
||||
}
|
||||
};
|
||||
|
||||
// Background with 1 row
|
||||
let (start_row, start_col, end_row, end_col) = (10, 20, 10, 130);
|
||||
let cached = create_cached(start_row, start_col, end_row, end_col);
|
||||
assert_eq!(
|
||||
grid_renderer::calculate_background_bounds(origin, cached, cell_size, max_columns),
|
||||
vec![rect_from_points(
|
||||
origin.x() + (start_col as f32) * cell_size.x(),
|
||||
origin.y() + (start_row as f32) * cell_size.y(),
|
||||
origin.x() + (end_col as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (end_row as f32 + 1.) * cell_size.y()
|
||||
)]
|
||||
);
|
||||
|
||||
// Background with 2 rows
|
||||
let (start_row, start_col, end_row, end_col) = (20, 30, 21, 100);
|
||||
let cached = create_cached(start_row, start_col, end_row, end_col);
|
||||
assert_eq!(
|
||||
grid_renderer::calculate_background_bounds(origin, cached, cell_size, max_columns),
|
||||
vec![
|
||||
rect_from_points(
|
||||
origin.x() + (start_col as f32) * cell_size.x(),
|
||||
origin.y() + (start_row as f32) * cell_size.y(),
|
||||
origin.x() + (max_columns as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (start_row as f32 + 1.) * cell_size.y()
|
||||
),
|
||||
rect_from_points(
|
||||
origin.x(),
|
||||
origin.y() + (start_row as f32 + 1.) * cell_size.y(),
|
||||
origin.x() + (end_col as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (end_row as f32 + 1.) * cell_size.y()
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
// Background with 3+ rows
|
||||
let assert_multi_row_selection_bounds =
|
||||
|start_row: usize, start_col: usize, end_row: usize, end_col: usize| {
|
||||
let cached = create_cached(start_row, start_col, end_row, end_col);
|
||||
assert_eq!(
|
||||
grid_renderer::calculate_background_bounds(origin, cached, cell_size, max_columns),
|
||||
vec![
|
||||
rect_from_points(
|
||||
origin.x() + (start_col as f32) * cell_size.x(),
|
||||
origin.y() + (start_row as f32) * cell_size.y(),
|
||||
origin.x() + (max_columns as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (start_row as f32 + 1.) * cell_size.y()
|
||||
),
|
||||
rect_from_points(
|
||||
origin.x(),
|
||||
origin.y() + (start_row as f32 + 1.) * cell_size.y(),
|
||||
origin.x() + (max_columns as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (end_row as f32) * cell_size.y()
|
||||
),
|
||||
rect_from_points(
|
||||
origin.x(),
|
||||
origin.y() + (end_row as f32) * cell_size.y(),
|
||||
origin.x() + (end_col as f32 + 1.) * cell_size.x(),
|
||||
origin.y() + (end_row as f32 + 1.) * cell_size.y()
|
||||
),
|
||||
]
|
||||
);
|
||||
};
|
||||
assert_multi_row_selection_bounds(30, 80, 32, 40); // 3 lines
|
||||
assert_multi_row_selection_bounds(40, 60, 43, 10); // 4 lines
|
||||
assert_multi_row_selection_bounds(50, 140, 59, 20); // 10 lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_selection_bounds() {
|
||||
let origin = vec2f(100., 100.);
|
||||
let size_info = SizeInfo::new(
|
||||
Vector2F::zero(),
|
||||
Pixels::new(2.),
|
||||
Pixels::new(4.),
|
||||
Pixels::new(8.),
|
||||
Pixels::new(16.),
|
||||
)
|
||||
.with_rows_and_columns(151, 151);
|
||||
|
||||
let cell_width = size_info.cell_width_px.as_f32();
|
||||
let cell_height = size_info.cell_height_px.as_f32();
|
||||
let horizontal_padding = size_info.padding_x_px.as_f32();
|
||||
let max_columns = size_info.columns - 1;
|
||||
|
||||
let make_selection_point = |row: usize, col: usize| SelectionPoint {
|
||||
row: row.into_lines(),
|
||||
col,
|
||||
};
|
||||
|
||||
let start = make_selection_point(10, 10);
|
||||
let end = make_selection_point(20, 50);
|
||||
|
||||
let assert_selection_bounds = |scroll_top: Lines| {
|
||||
assert_eq!(
|
||||
grid_renderer::calculate_selection_bounds(&start, &end, &size_info, scroll_top, origin),
|
||||
vec![
|
||||
rect_from_points(
|
||||
origin.x() + horizontal_padding + (start.col as f32) * cell_width,
|
||||
origin.y() + ((start.row - scroll_top).as_f64() as f32) * cell_height,
|
||||
origin.x() + horizontal_padding + (max_columns as f32 + 1.) * cell_width,
|
||||
origin.y() + ((start.row - scroll_top).as_f64() as f32 + 1.) * cell_height
|
||||
),
|
||||
rect_from_points(
|
||||
origin.x() + horizontal_padding,
|
||||
origin.y() + ((start.row - scroll_top).as_f64() as f32 + 1.) * cell_height,
|
||||
origin.x() + horizontal_padding + (max_columns as f32 + 1.) * cell_width,
|
||||
origin.y() + ((end.row - scroll_top).as_f64() as f32) * cell_height
|
||||
),
|
||||
rect_from_points(
|
||||
origin.x() + horizontal_padding,
|
||||
origin.y() + ((end.row - scroll_top).as_f64() as f32) * cell_height,
|
||||
origin.x() + horizontal_padding + (end.col as f32 + 1.) * cell_width,
|
||||
origin.y() + ((end.row - scroll_top).as_f64() as f32 + 1.) * cell_height
|
||||
),
|
||||
]
|
||||
);
|
||||
};
|
||||
assert_selection_bounds(5.into_lines()); // Without scroll clipping
|
||||
assert_selection_bounds(10.into_lines()); // Without scroll clipping (but on the cusp of clipping)
|
||||
assert_selection_bounds(80.into_lines()); // With scroll clipping
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! This module defines helper functions pertaining to the size/position of items in a Grid,
|
||||
//! such as the dimensions of a grid cell and the baseline position of text within a cell.
|
||||
use num_traits::Zero;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use warpui::elements::DEFAULT_UI_LINE_HEIGHT_RATIO;
|
||||
use warpui::fonts::Cache as FontCache;
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::text_layout::ComputeBaselinePositionFn;
|
||||
|
||||
/// 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
|
||||
/// cell width is the average advance of a glyph to its next (scaled up to pixel
|
||||
/// coordinates instead of font coordinates) and the height is the line height.
|
||||
pub fn grid_cell_dimensions(
|
||||
font_cache: &FontCache,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
line_height_ratio: f32,
|
||||
) -> Vector2F {
|
||||
let default_font_id = font_cache.select_font(font_family, Default::default());
|
||||
// Use the `m` character as a proxy for the average advance between two
|
||||
// characters in the font. It's safe to unwrap here as we expect the `m`
|
||||
// char to be present when we load the font.
|
||||
let (m_glyph, _) = font_cache
|
||||
.glyph_for_char(default_font_id, 'm', false)
|
||||
.expect("font must contain a glyph for the 'm' character");
|
||||
let advance = font_cache.glyph_advance(default_font_id, font_size, m_glyph);
|
||||
|
||||
// Get the font metrics that make up the total line height for the font.
|
||||
let ascent = font_cache.ascent(default_font_id, font_size);
|
||||
let descent = font_cache.descent(default_font_id, font_size);
|
||||
let leading = font_cache.leading(default_font_id, font_size);
|
||||
// Using a ratio, compared to the default line height, to appropriately scale the cell's Height.
|
||||
let height = ((ascent - descent + leading)
|
||||
* (line_height_ratio / DEFAULT_UI_LINE_HEIGHT_RATIO))
|
||||
.ceil()
|
||||
.max(1.);
|
||||
|
||||
match advance {
|
||||
Ok(advance) => {
|
||||
// The horizontal advance may be zero if the font is only meant to be rendered
|
||||
// vertically. In that case, default to an advance of 1.
|
||||
let horizontal_advance = if advance.x().is_zero() {
|
||||
1.
|
||||
} else {
|
||||
advance.x().round().max(1.)
|
||||
};
|
||||
Vector2F::new(horizontal_advance, height)
|
||||
}
|
||||
Err(error) => {
|
||||
// Return a default advance width if we couldn't load the advance from the glyph.
|
||||
log::error!("could not obtain advance for m glyph computing cell dimensions: {error}");
|
||||
Vector2F::new(1.0, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the baseline position using Grid-style calculations, specifically using font metrics
|
||||
/// such as leading/descent properties for the given font ID and font size.
|
||||
pub fn calculate_grid_baseline_position(
|
||||
font_cache: &FontCache,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
line_height_ratio: f32,
|
||||
cell_size_y: f32,
|
||||
) -> Vector2F {
|
||||
let default_font_id = font_cache.select_font(font_family, Default::default());
|
||||
// Leading is additional spacing between lines.
|
||||
// Descent is the general guideline for how far the font will go below the baseline (for single spacing).
|
||||
let leading = font_cache.leading(default_font_id, font_size).floor();
|
||||
let descent = font_cache.descent(default_font_id, font_size).floor();
|
||||
// TODO(advait): Consider having this simply return a f32 instead of a vec2f (need to change entire callstack).
|
||||
// Adjust descent to be scaled up by the line height ratio to move up the baseline
|
||||
// and approximately maintain the same position within a line.
|
||||
vec2f(
|
||||
0.,
|
||||
cell_size_y - leading
|
||||
+ (descent * (line_height_ratio / DEFAULT_UI_LINE_HEIGHT_RATIO).min(1.0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a closure that computes the baseline position in a Grid-style computation for the given font family.
|
||||
pub fn grid_compute_baseline_position_fn(font_family: FamilyId) -> ComputeBaselinePositionFn {
|
||||
Box::new(move |font_metrics| {
|
||||
let cell_size_y = grid_cell_dimensions(
|
||||
font_metrics.font_cache,
|
||||
font_family,
|
||||
font_metrics.font_size,
|
||||
font_metrics.line_height_ratio,
|
||||
)
|
||||
.y();
|
||||
calculate_grid_baseline_position(
|
||||
font_metrics.font_cache,
|
||||
font_family,
|
||||
font_metrics.font_size,
|
||||
font_metrics.line_height_ratio,
|
||||
cell_size_y,
|
||||
)
|
||||
.y()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
use chrono::{DateTime, Local, TimeZone as _};
|
||||
use futures::Future;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use warp_core::command::ExitCode;
|
||||
use warpui::{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,
|
||||
},
|
||||
};
|
||||
|
||||
mod up_arrow;
|
||||
pub(crate) use up_arrow::UpArrowHistoryConfig;
|
||||
|
||||
/// Data model for a history command persisted to sqlite, used as an intermediate representation
|
||||
/// between the sqlite schema (sqlite::model::Command) and the [`History`] model.
|
||||
#[derive(Debug)]
|
||||
pub struct PersistedCommand {
|
||||
pub id: i32,
|
||||
pub command: String,
|
||||
pub exit_code: Option<ExitCode>,
|
||||
pub start_ts: Option<DateTime<Local>>,
|
||||
pub completed_ts: Option<DateTime<Local>>,
|
||||
pub pwd: Option<String>,
|
||||
pub shell_host: Option<ShellHost>,
|
||||
pub session_id: Option<SessionId>,
|
||||
pub git_branch: Option<String>,
|
||||
pub workflow_id: Option<SyncId>,
|
||||
pub workflow_command: Option<String>,
|
||||
pub is_agent_executed: bool,
|
||||
}
|
||||
|
||||
impl From<crate::persistence::model::Command> for PersistedCommand {
|
||||
fn from(command: crate::persistence::model::Command) -> Self {
|
||||
PersistedCommand {
|
||||
id: command.id,
|
||||
command: command.command,
|
||||
exit_code: command.exit_code.map(ExitCode::from),
|
||||
start_ts: command
|
||||
.start_ts
|
||||
.as_ref()
|
||||
.map(|time| Local.from_utc_datetime(time)),
|
||||
completed_ts: command
|
||||
.completed_ts
|
||||
.as_ref()
|
||||
.map(|time| Local.from_utc_datetime(time)),
|
||||
pwd: command.pwd,
|
||||
shell_host: match (command.shell, command.username, command.hostname) {
|
||||
(Some(shell), Some(username), Some(hostname)) => {
|
||||
ShellType::from_name(shell.as_str()).map(|shell_type| ShellHost {
|
||||
shell_type,
|
||||
user: username,
|
||||
hostname,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
session_id: command.session_id.and_then(|session_id| {
|
||||
TryInto::<u64>::try_into(session_id)
|
||||
.ok()
|
||||
.map(SessionId::from)
|
||||
}),
|
||||
git_branch: command.git_branch,
|
||||
workflow_id: command.cloud_workflow_id.and_then(|workflow_id| {
|
||||
if let Some(client_id) = ClientId::from_hash(workflow_id.as_str()) {
|
||||
Some(SyncId::ClientId(client_id))
|
||||
} else {
|
||||
WorkflowId::from_hash(workflow_id.as_str())
|
||||
.map(|id| SyncId::ServerId(id.into()))
|
||||
}
|
||||
}),
|
||||
workflow_command: command.workflow_command,
|
||||
is_agent_executed: command.is_agent_executed.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellHost {
|
||||
// This field was originally named `shell` so mark it as an alias for
|
||||
// backwards compatibility.
|
||||
#[serde(alias = "shell")]
|
||||
pub shell_type: ShellType,
|
||||
pub user: String,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
impl ShellHost {
|
||||
pub fn from_session(session: &Session) -> Self {
|
||||
Self {
|
||||
shell_type: session.shell().shell_type(),
|
||||
user: session.user().to_owned(),
|
||||
hostname: session.hostname().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn from_session_info(session_info: &super::model::session::SessionInfo) -> Self {
|
||||
Self {
|
||||
shell_type: session_info.shell.shell_type(),
|
||||
user: session_info.user.clone(),
|
||||
hostname: session_info.hostname.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_from_persisted_block(block: &crate::persistence::model::Block) -> Option<Self> {
|
||||
block
|
||||
.shell
|
||||
.as_ref()
|
||||
.and_then(|name| ShellType::from_name(&name[..]))
|
||||
.map(|shell| ShellHost {
|
||||
shell_type: shell,
|
||||
user: block
|
||||
.user
|
||||
.clone()
|
||||
.unwrap_or_else(|| "local:user".to_owned()),
|
||||
hostname: block
|
||||
.host
|
||||
.clone()
|
||||
.unwrap_or_else(|| "local:host".to_owned()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the async task for reading the shell's history file for a given session.
|
||||
#[derive(Debug)]
|
||||
|
||||
enum ReadHistoryFileState {
|
||||
InProgress {
|
||||
/// Commands that were executed by the user while the history file was being loaded. When
|
||||
/// the history file has finished loading, these commands are appended to the history
|
||||
/// file's commands in the in-memory representation of the session's history.
|
||||
///
|
||||
/// These commands chronologically ordered by time of execution.
|
||||
session_commands_to_append: Vec<Arc<HistoryEntry>>,
|
||||
|
||||
/// Session IDs for sessions that are bootstrapped and awaiting a loaded histfile for the
|
||||
/// corresponding shell host.
|
||||
session_ids: HashSet<SessionId>,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub enum HistoryEvent {
|
||||
/// History has been initialized for the session with the contained ID.
|
||||
Initialized(SessionId),
|
||||
}
|
||||
|
||||
/// This holds the aggregated data from the "commands" table in sqlite. We aggregate as a means of
|
||||
/// de-duping, and store data mostly for the most recent execution for each command.
|
||||
#[derive(Debug)]
|
||||
struct CommandHistorySummary {
|
||||
/// The execution metadata from the latest time a particular command was run.
|
||||
most_recent_entry: HistoryEntry,
|
||||
/// Counts the number of executions in the "commands" table. Note that this may not match the
|
||||
/// count in the HISTFILE.
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl CommandHistorySummary {
|
||||
fn new(most_recent_entry: HistoryEntry) -> Self {
|
||||
Self {
|
||||
most_recent_entry,
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct History {
|
||||
/// For each ShellHost, the de-duped commands from the sqlite "commands" table is stored here.
|
||||
/// Each time a history file is read, it gets "joined" to the commands in here to add the
|
||||
/// execution metadata from the most recent run.
|
||||
persisted_commands_summary: HashMap<ShellHost, HashMap<String, CommandHistorySummary>>,
|
||||
|
||||
/// Entries from the history file for the host. Immutable once loaded and
|
||||
/// shared between sessions.
|
||||
history_file_commands: HashMap<ShellHost, Vec<Arc<HistoryEntry>>>,
|
||||
|
||||
/// Global history entries across all sessions for each host. Only grows. Deduping
|
||||
/// is handled by marking session_skip_indices.
|
||||
/// Note that restored block commands are appended to session_commands on startup.
|
||||
/// Note: To present commands chronologically across hosts, we can add a timestamp to each history entry
|
||||
session_commands: HashMap<ShellHost, Vec<Arc<HistoryEntry>>>,
|
||||
|
||||
/// Indices to skip when rendering, by session. Indices are into the concatenation of
|
||||
/// history file commands + session commands, which is the history list for that host.
|
||||
session_skip_indices: HashMap<SessionId, HashSet<usize>>,
|
||||
session_start_indices: HashMap<SessionId, usize>,
|
||||
|
||||
/// A map of session ID to the state of the background task to read the shell history file for
|
||||
/// the corresponding session.
|
||||
read_history_file_state: HashMap<ShellHost, ReadHistoryFileState>,
|
||||
|
||||
session_id_to_shell_host: HashMap<SessionId, ShellHost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum LinkedWorkflowData {
|
||||
/// The history entry is linked to a `CloudWorkflow` by its ID.
|
||||
Id(SyncId),
|
||||
|
||||
/// The history entry is linked to a local `Workflow` by its command.
|
||||
///
|
||||
/// Local workflows are not keyed by any common ID.
|
||||
Command(String),
|
||||
}
|
||||
|
||||
impl LinkedWorkflowData {
|
||||
/// Returns the WorkflowType and WorkflowSource corresponding to this `LinkedWorkflowData`, if
|
||||
/// any.
|
||||
pub fn linked_workflow(&self, ctx: &AppContext) -> Option<(WorkflowType, WorkflowSource)> {
|
||||
match self {
|
||||
LinkedWorkflowData::Id(id) => {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let workflow = cloud_model.get_workflow(id);
|
||||
let workflow_source = match CloudViewModel::as_ref(ctx).object_space(&id.uid(), ctx)
|
||||
{
|
||||
Some(Space::Team { team_uid }) => WorkflowSource::Team { team_uid },
|
||||
_ => WorkflowSource::PersonalCloud,
|
||||
};
|
||||
workflow.map(|workflow| {
|
||||
(
|
||||
WorkflowType::Cloud(Box::new(workflow.clone())),
|
||||
workflow_source,
|
||||
)
|
||||
})
|
||||
}
|
||||
LinkedWorkflowData::Command(workflow_command) => {
|
||||
if let Some((workflow_source, workflow)) = LocalWorkflows::as_ref(ctx)
|
||||
.workflow_with_command(ctx, workflow_command.as_str())
|
||||
{
|
||||
Some((WorkflowType::Local(workflow.clone()), workflow_source))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For history entries coming from the shell history file, only the command is populated.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HistoryEntry {
|
||||
pub session_id: Option<SessionId>,
|
||||
pub command: String,
|
||||
pub pwd: Option<String>,
|
||||
pub start_ts: Option<DateTime<Local>>,
|
||||
pub completed_ts: Option<DateTime<Local>>,
|
||||
pub exit_code: Option<ExitCode>,
|
||||
pub git_head: Option<String>,
|
||||
pub shell_host: Option<ShellHost>,
|
||||
|
||||
/// The ID of the `CloudWorkflow` used to construct this command.
|
||||
workflow_id: Option<SyncId>,
|
||||
|
||||
/// The templated command contained in the `Workflow` used to construct the executed
|
||||
/// command.
|
||||
workflow_command: Option<String>,
|
||||
|
||||
pub is_for_restored_block: bool,
|
||||
|
||||
/// Whether this command was executed by an AI agent.
|
||||
pub is_agent_executed: bool,
|
||||
}
|
||||
|
||||
fn serialized_block_is_agent_executed(block: &SerializedBlock) -> bool {
|
||||
let Some(ai_metadata) = block.ai_metadata.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
serde_json::from_str::<SerializedAIMetadata>(ai_metadata)
|
||||
.ok()
|
||||
.map(AgentInteractionMetadata::from)
|
||||
.is_some_and(|metadata| metadata.requested_command_action_id().is_some())
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
pub fn command_only<S: Into<String>>(command: S) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
session_id: None,
|
||||
pwd: None,
|
||||
start_ts: None,
|
||||
completed_ts: None,
|
||||
workflow_id: None,
|
||||
workflow_command: None,
|
||||
exit_code: None,
|
||||
git_head: None,
|
||||
shell_host: None,
|
||||
is_for_restored_block: false,
|
||||
is_agent_executed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_at_time(
|
||||
command: String,
|
||||
start_ts: DateTime<Local>,
|
||||
session_id: Option<SessionId>,
|
||||
is_for_restored_block: bool,
|
||||
) -> Self {
|
||||
let mut entry = Self::command_only(command);
|
||||
entry.start_ts = Some(start_ts);
|
||||
entry.session_id = session_id;
|
||||
entry.is_for_restored_block = is_for_restored_block;
|
||||
entry
|
||||
}
|
||||
|
||||
pub fn for_session_command(
|
||||
command: String,
|
||||
active_block: &Block,
|
||||
session: &Session,
|
||||
workflow_id: Option<SyncId>,
|
||||
workflow_command: Option<String>,
|
||||
is_agent_executed: bool,
|
||||
) -> Self {
|
||||
HistoryEntry {
|
||||
session_id: Some(session.id()),
|
||||
command,
|
||||
pwd: active_block.pwd().map(|pwd| pwd.to_owned()),
|
||||
start_ts: active_block.start_ts().copied(),
|
||||
workflow_id,
|
||||
workflow_command,
|
||||
git_head: active_block
|
||||
.git_branch()
|
||||
.map(|git_branch| git_branch.to_owned()),
|
||||
completed_ts: None,
|
||||
exit_code: None,
|
||||
shell_host: active_block.shell_host().clone(),
|
||||
is_for_restored_block: false,
|
||||
is_agent_executed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_restored_block(command: String, block: &Block) -> Self {
|
||||
HistoryEntry {
|
||||
session_id: block.session_id(),
|
||||
command,
|
||||
pwd: block.pwd().map(|pwd| pwd.to_owned()),
|
||||
start_ts: block.start_ts().copied(),
|
||||
workflow_id: None,
|
||||
workflow_command: None,
|
||||
git_head: block.git_branch().map(|git_branch| git_branch.to_owned()),
|
||||
shell_host: block.shell_host().clone(),
|
||||
completed_ts: block.completed_ts().copied(),
|
||||
exit_code: Some(block.exit_code()),
|
||||
is_for_restored_block: true,
|
||||
is_agent_executed: block.requested_command_action_id().is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_completed_block(command: String, block: &SerializedBlock) -> Self {
|
||||
HistoryEntry {
|
||||
session_id: block.session_id,
|
||||
command,
|
||||
pwd: block.pwd.clone(),
|
||||
start_ts: block.start_ts,
|
||||
completed_ts: block.completed_ts,
|
||||
workflow_id: None,
|
||||
workflow_command: None,
|
||||
exit_code: Some(block.exit_code),
|
||||
git_head: block.git_head.clone(),
|
||||
shell_host: block.shell_host.clone(),
|
||||
is_for_restored_block: false,
|
||||
is_agent_executed: serialized_block_is_agent_executed(block),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an `Option` containing the workflow linked to this command, if any.
|
||||
///
|
||||
/// First looks up the workflow using `self.workflow_id`, then falls back to looking up the
|
||||
/// workflow using `self.workflow_command`, if any.
|
||||
pub fn linked_workflow(&self, app: &AppContext) -> Option<Workflow> {
|
||||
match (&self.workflow_id, &self.workflow_command) {
|
||||
(Some(workflow_id), _) => CloudModel::as_ref(app)
|
||||
.get_workflow(workflow_id)
|
||||
.map(|workflow| workflow.model().data.clone()),
|
||||
(_, Some(workflow_command)) => LocalWorkflows::as_ref(app)
|
||||
.workflow_with_command(app, workflow_command)
|
||||
.map(|(_, workflow)| workflow.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates that at least one of the optional rich history fields is Some.
|
||||
pub fn has_metadata(&self) -> bool {
|
||||
// Destructure this so that we _must_ update this method when new metadata fields are added
|
||||
// to Self. `completed_ts` isn't useful without start_ts, so that is omitted in this check.
|
||||
let HistoryEntry {
|
||||
session_id: _,
|
||||
command: _,
|
||||
is_for_restored_block: _,
|
||||
is_agent_executed: _,
|
||||
pwd,
|
||||
start_ts,
|
||||
completed_ts: _,
|
||||
workflow_id,
|
||||
exit_code,
|
||||
git_head,
|
||||
workflow_command,
|
||||
shell_host: _,
|
||||
} = self;
|
||||
pwd.is_some()
|
||||
|| start_ts.is_some()
|
||||
|| workflow_id.is_some()
|
||||
|| exit_code.is_some()
|
||||
|| git_head.is_some()
|
||||
|| workflow_command.is_some()
|
||||
}
|
||||
|
||||
/// Returns `LinkedWorkflowData` referring to the workflow used to create this history command,
|
||||
/// if any.
|
||||
pub fn linked_workflow_data(&self) -> Option<LinkedWorkflowData> {
|
||||
match (&self.workflow_id, &self.workflow_command) {
|
||||
(Some(workflow_id), _) => Some(LinkedWorkflowData::Id(*workflow_id)),
|
||||
(_, Some(workflow_command)) => {
|
||||
Some(LinkedWorkflowData::Command(workflow_command.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PersistedCommand> for HistoryEntry {
|
||||
fn from(command: PersistedCommand) -> Self {
|
||||
HistoryEntry {
|
||||
session_id: command.session_id,
|
||||
command: command.command,
|
||||
exit_code: command.exit_code,
|
||||
start_ts: command.start_ts,
|
||||
completed_ts: command.completed_ts,
|
||||
pwd: command.pwd,
|
||||
git_head: command.git_branch,
|
||||
workflow_id: command.workflow_id,
|
||||
workflow_command: command.workflow_command,
|
||||
shell_host: command.shell_host,
|
||||
is_for_restored_block: false,
|
||||
is_agent_executed: command.is_agent_executed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for History {
|
||||
type Event = HistoryEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for History {}
|
||||
|
||||
impl History {
|
||||
pub fn new(persisted_commands: Vec<PersistedCommand>) -> Self {
|
||||
log::debug!("Creating new History model with persisted commands {persisted_commands:?}");
|
||||
let mut persisted_commands_summary =
|
||||
HashMap::<ShellHost, HashMap<String, CommandHistorySummary>>::new();
|
||||
|
||||
for command in persisted_commands {
|
||||
if let Some(shell_host) = command.shell_host.as_ref() {
|
||||
let summaries = persisted_commands_summary
|
||||
.entry(shell_host.clone())
|
||||
.or_default();
|
||||
let hist_entry: HistoryEntry = command.into();
|
||||
summaries
|
||||
.entry(hist_entry.command.clone())
|
||||
.and_modify(|summary| summary.count += 1)
|
||||
.or_insert(CommandHistorySummary::new(hist_entry));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
persisted_commands_summary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Initializes the history model for the given session.
|
||||
///
|
||||
/// Command history from the shell's history file is read asynchronously on a background
|
||||
/// thread. Depending on whether or not the session is local or remote, history may be read
|
||||
/// directly from disk or via an in-band command.
|
||||
pub fn init_session(&mut self, session: Arc<Session>, ctx: &mut ModelContext<Self>) {
|
||||
if self.session_start_indices.contains_key(&session.id()) {
|
||||
log::debug!(
|
||||
"In init_session but history was already initialized for session {:?}",
|
||||
session.id()
|
||||
);
|
||||
} else {
|
||||
let session_clone = session.clone();
|
||||
let is_kaspersky_running = Self::is_kaspersky_running(ctx);
|
||||
self.init_session_with(
|
||||
session,
|
||||
async move { session_clone.read_history(is_kaspersky_running).await },
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines whether Kaspersky is running on the system. We only care if
|
||||
/// Kaspersky is running on Windows, so we return false for other platforms.
|
||||
#[cfg_attr(not(windows), allow(unused_variables))]
|
||||
fn is_kaspersky_running(ctx: &mut ModelContext<Self>) -> bool {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(windows)] {
|
||||
crate::util::windows::is_kaspersky_running(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the history model history model for the given session, where
|
||||
/// `read_history_file_future` is a Future that completes with the contents of the shell's
|
||||
/// history file.
|
||||
///
|
||||
/// `read_history_file_state` is executed in a background task.
|
||||
pub fn init_session_with<F>(
|
||||
&mut self,
|
||||
session: Arc<Session>,
|
||||
read_history_file_future: F,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) where
|
||||
F: 'static + Future<Output = Vec<String>> + Send,
|
||||
{
|
||||
let host = ShellHost {
|
||||
shell_type: session.shell().shell_type(),
|
||||
user: session.user().to_owned(),
|
||||
hostname: session.hostname().to_owned(),
|
||||
};
|
||||
|
||||
let session_id = session.id();
|
||||
log::debug!(
|
||||
"Setting shell history for shell {:?} and session id {:?}",
|
||||
host.shell_type.name(),
|
||||
session_id
|
||||
);
|
||||
|
||||
self.session_id_to_shell_host
|
||||
.insert(session_id, host.clone());
|
||||
|
||||
match self.read_history_file_state.get_mut(&host) {
|
||||
None => {
|
||||
let mut session_ids = HashSet::new();
|
||||
session_ids.insert(session_id);
|
||||
self.read_history_file_state.insert(
|
||||
host.clone(),
|
||||
ReadHistoryFileState::InProgress {
|
||||
session_commands_to_append: vec![],
|
||||
session_ids,
|
||||
},
|
||||
);
|
||||
let host_clone = host.clone();
|
||||
ctx.spawn(
|
||||
read_history_file_future,
|
||||
move |me, history_file_commands, ctx| {
|
||||
// `session_commands_to_append` are commands executed by the user while the
|
||||
// history file was being loaded.
|
||||
let (session_commands_to_append, session_ids) = match me
|
||||
.read_history_file_state
|
||||
.insert(host_clone, ReadHistoryFileState::Done)
|
||||
{
|
||||
Some(ReadHistoryFileState::InProgress {
|
||||
session_commands_to_append,
|
||||
session_ids,
|
||||
}) => (Some(session_commands_to_append), session_ids),
|
||||
_ => {
|
||||
// This branch should be unreachable, but don't panic!
|
||||
log::warn!("Marking ReadHistoryFileState::Done for session when \
|
||||
its previous state was not ReadHistoryFileState::InProgress.");
|
||||
let mut session_ids = HashSet::new();
|
||||
session_ids.insert(session_id);
|
||||
(None, session_ids)
|
||||
}
|
||||
};
|
||||
me.load_history_file_commands(
|
||||
history_file_commands,
|
||||
session_commands_to_append,
|
||||
session_ids,
|
||||
host,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(ReadHistoryFileState::Done) => {
|
||||
let Some(history_file_commands) = self.history_file_commands.get(&host) else {
|
||||
log::error!(
|
||||
"History file commands should exist if history file has been read."
|
||||
);
|
||||
return;
|
||||
};
|
||||
let session_commands_length = self
|
||||
.session_commands
|
||||
.get(&host)
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let session_start_index = history_file_commands.len() + session_commands_length;
|
||||
|
||||
let mut session_ids = HashSet::new();
|
||||
session_ids.insert(session_id);
|
||||
self.initialize_session_start_and_skip_indices(
|
||||
session_ids,
|
||||
host,
|
||||
session_start_index,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
Some(ReadHistoryFileState::InProgress { session_ids, .. }) => {
|
||||
session_ids.insert(session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the given `history_file_commands` into `HistoryEntry`'s and sets the
|
||||
/// `history_file_commands` entry for the session with the given ID.
|
||||
///
|
||||
/// `session_commands_to_append` are commands that have been executed by the user while loading
|
||||
/// history file commands; these commands are inserted directly into the session history.
|
||||
fn load_history_file_commands(
|
||||
&mut self,
|
||||
history_file_commands: Vec<String>,
|
||||
session_commands_to_append: Option<Vec<Arc<HistoryEntry>>>,
|
||||
session_ids: HashSet<SessionId>,
|
||||
host: ShellHost,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let deduped_history_file_commands = dedupe_from_last(history_file_commands);
|
||||
|
||||
let mut start_index = deduped_history_file_commands.len();
|
||||
self.history_file_commands.insert(
|
||||
host.clone(),
|
||||
deduped_history_file_commands
|
||||
.into_iter()
|
||||
.map(|command| {
|
||||
self.persisted_commands_summary
|
||||
.get(&host)
|
||||
.and_then(|summaries| summaries.get(&command))
|
||||
.map(|summary| summary.most_recent_entry.clone())
|
||||
.unwrap_or_else(|| HistoryEntry::command_only(command))
|
||||
})
|
||||
.map(Arc::new)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if let Some(session_commands_to_append) = session_commands_to_append {
|
||||
start_index += session_commands_to_append.len();
|
||||
self.session_commands
|
||||
.insert(host.clone(), session_commands_to_append);
|
||||
}
|
||||
|
||||
self.initialize_session_start_and_skip_indices(session_ids, host, start_index, ctx);
|
||||
}
|
||||
|
||||
/// Initializes the 'session start index' and 'skip indices' for the given session.
|
||||
fn initialize_session_start_and_skip_indices(
|
||||
&mut self,
|
||||
session_ids: HashSet<SessionId>,
|
||||
host: ShellHost,
|
||||
session_start_index: usize,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
log::debug!("Loading command history from start index {session_start_index}.");
|
||||
for session_id in &session_ids {
|
||||
self.session_start_indices
|
||||
.insert(*session_id, session_start_index);
|
||||
}
|
||||
|
||||
self.session_commands.entry(host).or_default();
|
||||
|
||||
for session_id in session_ids {
|
||||
// Dedupe commands for the new session
|
||||
// There could be duplicate live commands from other sessions of the same host
|
||||
let mut seen_commands: HashSet<&str> = HashSet::new();
|
||||
let mut skip_index_set: HashSet<usize> = HashSet::new();
|
||||
self.session_skip_indices.insert(session_id, HashSet::new());
|
||||
for (idx, &entry) in self
|
||||
.commands(session_id)
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("History commands are empty for session {session_id:?}");
|
||||
Vec::new()
|
||||
})
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
{
|
||||
if seen_commands.contains(entry.command.as_str()) {
|
||||
skip_index_set.insert(idx);
|
||||
} else {
|
||||
seen_commands.insert(entry.command.as_str());
|
||||
}
|
||||
}
|
||||
self.session_skip_indices.insert(session_id, skip_index_set);
|
||||
ctx.emit(HistoryEvent::Initialized(session_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff this session's history is ready to be queried.
|
||||
///
|
||||
/// A session's history is only queryable after the histfile is read.
|
||||
pub fn is_queryable(&self, session_id: &SessionId) -> bool {
|
||||
self.session_start_indices.contains_key(session_id)
|
||||
}
|
||||
|
||||
/// Returns true iff this session's history can be appended.
|
||||
///
|
||||
/// A session's history is appendable as soon as the corresponding
|
||||
/// session is registered with the [`History`] model.
|
||||
pub fn is_appendable(&self, session_id: &SessionId) -> bool {
|
||||
self.session_id_to_shell_host.contains_key(session_id)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
pub fn is_session_initialized(&self, session_id: &SessionId) -> bool {
|
||||
self.is_queryable(session_id) && self.is_appendable(session_id)
|
||||
}
|
||||
|
||||
/// Appends restored block commands from session restoration.
|
||||
/// We don't persist session_id for restored blocks, so the restored HistoryEntries are missing session_id.
|
||||
/// We manually set the session_id of each HistoryEntry here to ensure it's populated and correct.
|
||||
pub fn append_restored_commands(&mut self, session_id: SessionId, commands: Vec<HistoryEntry>) {
|
||||
let commands: Vec<HistoryEntry> = commands
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
c.session_id = Some(session_id);
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
// All entries already have metadata and is_for_restored_block set to true
|
||||
self.append_commands(session_id, commands);
|
||||
}
|
||||
|
||||
/// Appends an array of commands to the session's history (session_commands, not history_file_commands).
|
||||
/// The commands should be chronologically ordered by time of execution.
|
||||
///
|
||||
/// If the read for the history file is still in progress, queues a background task to append
|
||||
/// the given commands once the read is complete. Otherwise, synchronously appends the given
|
||||
/// commands.
|
||||
pub fn append_commands(&mut self, session_id: SessionId, commands: Vec<HistoryEntry>) {
|
||||
let Some(shell_host) = self.session_id_to_shell_host.get(&session_id) else {
|
||||
log::warn!("ShellHost should be populated in the map for all bootstrapped sessions.");
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.read_history_file_state
|
||||
.get_mut(shell_host)
|
||||
.expect("ReadHistoryFileState should exist for session.")
|
||||
{
|
||||
ReadHistoryFileState::InProgress {
|
||||
session_commands_to_append,
|
||||
..
|
||||
} => {
|
||||
session_commands_to_append.extend(commands.into_iter().map(Arc::new));
|
||||
}
|
||||
ReadHistoryFileState::Done => {
|
||||
let mut commands_set = HashSet::new();
|
||||
let mut commands_set_skip_indices = Vec::new();
|
||||
|
||||
// First dedupe the commands to append to the history and filter out empty commands.
|
||||
for (idx, command) in commands.iter().enumerate().rev() {
|
||||
if commands_set.contains(command.command.as_str()) || command.command.is_empty()
|
||||
{
|
||||
commands_set_skip_indices.push(idx);
|
||||
} else {
|
||||
commands_set.insert(command.command.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(history_file_commands) = self.history_file_commands.get(shell_host) else {
|
||||
log::warn!(
|
||||
"history_file_commands should be set if ReadHistoryFileState is Done."
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(session_commands) = self.session_commands.get_mut(shell_host) else {
|
||||
log::warn!("session_commands should be set if ReadHistoryFileState is Done.");
|
||||
return;
|
||||
};
|
||||
|
||||
let skip_indices = &mut self.session_skip_indices;
|
||||
let mut last_index = 0;
|
||||
for (idx, h) in history_file_commands
|
||||
.iter()
|
||||
.chain(session_commands.iter())
|
||||
.enumerate()
|
||||
{
|
||||
// Mark to skip this command if it exists already
|
||||
if commands_set.contains(h.command.as_str()) {
|
||||
skip_indices.entry(session_id).or_default().insert(idx);
|
||||
}
|
||||
last_index = idx;
|
||||
}
|
||||
|
||||
for command in commands {
|
||||
session_commands.push(Arc::new(command));
|
||||
}
|
||||
|
||||
// Offset the skip indices with the length of all commands in history.
|
||||
for idx in commands_set_skip_indices {
|
||||
let entry = skip_indices.entry(session_id).or_default();
|
||||
entry.insert(last_index + idx + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `commands` returns the history file commands for the session's host, concatenated
|
||||
/// with session commands.
|
||||
///
|
||||
/// It is important that it is this concatenation specifically, because self.session_skip_indices is
|
||||
/// an index into this specific concatenation.
|
||||
pub fn commands(&self, session_id: SessionId) -> Option<Vec<&HistoryEntry>> {
|
||||
self.collect_visible_commands_for_session(session_id, |h| h.as_ref())
|
||||
}
|
||||
|
||||
fn collect_visible_commands_for_session<'a, T>(
|
||||
&'a self,
|
||||
session_id: SessionId,
|
||||
mut to_output: impl FnMut(&'a Arc<HistoryEntry>) -> T,
|
||||
) -> Option<Vec<T>> {
|
||||
let Some(shell_host) = self.session_id_to_shell_host.get(&session_id) else {
|
||||
log::warn!("ShellHost should be populated in the map for all bootstrapped sessions.");
|
||||
return None;
|
||||
};
|
||||
let Some(session_start_index) = self.session_start_indices.get(&session_id) else {
|
||||
log::warn!("Session start index for session {session_id:?} is None.");
|
||||
return None;
|
||||
};
|
||||
let Some(skip_indices) = self.session_skip_indices.get(&session_id) else {
|
||||
log::warn!("Skip indices for session {session_id:?} are empty.");
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(histfile_commands) = self.history_file_commands.get(shell_host) else {
|
||||
log::warn!("Histfile commands for session {session_id:?} are empty.");
|
||||
return None;
|
||||
};
|
||||
let Some(session_commands) = self.session_commands.get(shell_host) else {
|
||||
log::warn!("Session commands for session {session_id:?} are empty.");
|
||||
return None;
|
||||
};
|
||||
let commands: Vec<T> = histfile_commands
|
||||
.iter()
|
||||
.chain(session_commands.iter())
|
||||
.enumerate()
|
||||
.filter_map(|(idx, h)| {
|
||||
if skip_indices.contains(&idx) {
|
||||
return None;
|
||||
}
|
||||
// Restored blocks may appear before the session start index because they may be
|
||||
// loaded before the history file has been read. However, restored block commands
|
||||
// have the same semantics as post-start index session commands -- they should only
|
||||
// be shown in the session where they were restored.
|
||||
if idx < *session_start_index && !h.is_for_restored_block {
|
||||
// This history entry was from before the session's history
|
||||
// started, so we include it.
|
||||
return Some(to_output(h));
|
||||
}
|
||||
// Otherwise, we need to check if the history entry is from this session.
|
||||
if let Some(history_session_id) = h.session_id {
|
||||
if history_session_id == session_id {
|
||||
Some(to_output(h))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// No session id, so command is from the history file
|
||||
Some(to_output(h))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// `commands_shared` returns the same logical set as [`Self::commands`], but with shared
|
||||
/// ownership so callers can keep an owned snapshot without deep-cloning command data.
|
||||
pub fn commands_shared(&self, session_id: SessionId) -> Option<Vec<Arc<HistoryEntry>>> {
|
||||
self.collect_visible_commands_for_session(session_id, Arc::clone)
|
||||
}
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self, session_id: SessionId) -> usize {
|
||||
self.commands(session_id)
|
||||
.map(|c| c.len())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self, session_id: SessionId) -> bool {
|
||||
self.len(session_id) == 0
|
||||
}
|
||||
|
||||
#[cfg(feature = "integration_tests")]
|
||||
pub fn session_commands(&self) -> &HashMap<ShellHost, Vec<Arc<HistoryEntry>>> {
|
||||
&self.session_commands
|
||||
}
|
||||
|
||||
pub fn mark_command_as_finished(
|
||||
&mut self,
|
||||
session_id: SessionId,
|
||||
command_start_ts: DateTime<Local>,
|
||||
command_completed_ts: DateTime<Local>,
|
||||
exit_code: ExitCode,
|
||||
) {
|
||||
let Some(shell_host) = self.session_id_to_shell_host.get(&session_id) else {
|
||||
log::warn!("ShellHost should be populated in the map for all bootstrapped sessions.");
|
||||
return;
|
||||
};
|
||||
let session_commands = match self
|
||||
.read_history_file_state
|
||||
.get_mut(shell_host)
|
||||
.expect("ReadHistoryFileState should exist for session.")
|
||||
{
|
||||
ReadHistoryFileState::InProgress {
|
||||
session_commands_to_append,
|
||||
..
|
||||
} => session_commands_to_append,
|
||||
ReadHistoryFileState::Done => {
|
||||
let Some(commands) = self.session_commands.get_mut(shell_host) else {
|
||||
return;
|
||||
};
|
||||
commands
|
||||
}
|
||||
};
|
||||
|
||||
for entry in session_commands.iter_mut().rev() {
|
||||
if let Some(entry_start_ts) = &entry.start_ts {
|
||||
if entry_start_ts.timestamp_millis() == command_start_ts.timestamp_millis() {
|
||||
let entry = Arc::make_mut(entry);
|
||||
entry.exit_code = Some(exit_code);
|
||||
entry.completed_ts = Some(command_completed_ts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_tests.rs"]
|
||||
pub mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, EntityId, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::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 {
|
||||
pub include_commands: bool,
|
||||
pub include_prompts: bool,
|
||||
}
|
||||
|
||||
impl UpArrowHistoryConfig {
|
||||
/// Derives the config from the current input config.
|
||||
/// When the input is locked to a specific type, only that type is included.
|
||||
/// When unlocked (auto-detection), both types are included.
|
||||
pub fn for_input_config(input_config: &InputConfig) -> Self {
|
||||
if input_config.is_locked {
|
||||
Self {
|
||||
include_commands: input_config.is_shell(),
|
||||
include_prompts: input_config.is_ai(),
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
include_commands: true,
|
||||
include_prompts: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_and_dedupe_suggestions<'a>(
|
||||
mut suggestions: Vec<HistoryInputSuggestion<'a>>,
|
||||
session_id: Option<SessionId>,
|
||||
all_live_session_ids: &HashSet<SessionId>,
|
||||
) -> Vec<HistoryInputSuggestion<'a>> {
|
||||
suggestions.sort_by(|a, b| a.cmp(b, session_id, all_live_session_ids));
|
||||
|
||||
// Deduplicate commands and AI queries separately: keep the latest occurrence for each type.
|
||||
let mut seen_commands: HashSet<&str> = HashSet::new();
|
||||
let mut seen_ai_queries: HashSet<&str> = HashSet::new();
|
||||
let mut skip_indices: HashSet<usize> = HashSet::new();
|
||||
for (idx, suggestion) in suggestions.iter().enumerate().rev() {
|
||||
let text = suggestion.text();
|
||||
if suggestion.is_ai_query() {
|
||||
if seen_ai_queries.contains(text) {
|
||||
skip_indices.insert(idx);
|
||||
} else {
|
||||
seen_ai_queries.insert(text);
|
||||
}
|
||||
} else if seen_commands.contains(text) {
|
||||
skip_indices.insert(idx);
|
||||
} else {
|
||||
seen_commands.insert(text);
|
||||
}
|
||||
}
|
||||
|
||||
suggestions
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !skip_indices.contains(idx))
|
||||
.map(|(_, suggestion)| suggestion)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl History {
|
||||
pub(crate) fn up_arrow_suggestions_for_terminal_view<'a>(
|
||||
&'a self,
|
||||
terminal_view_id: EntityId,
|
||||
session_id: Option<SessionId>,
|
||||
config: UpArrowHistoryConfig,
|
||||
app: &'a AppContext,
|
||||
) -> Vec<HistoryInputSuggestion<'a>> {
|
||||
let ignored_suggestions = IgnoredSuggestionsModel::handle(app).as_ref(app);
|
||||
|
||||
let include_agent_commands = *AISettings::handle(app)
|
||||
.as_ref(app)
|
||||
.include_agent_commands_in_history;
|
||||
|
||||
let commands = session_id
|
||||
.and_then(|session_id| self.commands(session_id))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
!ignored_suggestions.is_ignored(&entry.command, SuggestionType::ShellCommand)
|
||||
})
|
||||
.filter(move |entry| include_agent_commands || !entry.is_agent_executed)
|
||||
.map(|entry| HistoryInputSuggestion::Command { entry });
|
||||
|
||||
let should_include_prompts = config.include_prompts
|
||||
&& FeatureFlag::AgentMode.is_enabled()
|
||||
&& AISettings::handle(app).as_ref(app).is_any_ai_enabled(app);
|
||||
let all_live_session_ids = self.all_live_session_ids();
|
||||
if !should_include_prompts {
|
||||
if !config.include_commands {
|
||||
return vec![];
|
||||
}
|
||||
return sort_and_dedupe_suggestions(
|
||||
commands.collect(),
|
||||
session_id,
|
||||
&all_live_session_ids,
|
||||
);
|
||||
}
|
||||
|
||||
let ai_queries = BlocklistAIHistoryModel::handle(app)
|
||||
.as_ref(app)
|
||||
.all_ai_queries(Some(terminal_view_id))
|
||||
.filter(|query| {
|
||||
!ignored_suggestions.is_ignored(&query.query_text, SuggestionType::AIQuery)
|
||||
})
|
||||
.map(|entry| HistoryInputSuggestion::AIQuery { entry });
|
||||
|
||||
let suggestions: Vec<HistoryInputSuggestion<'a>> =
|
||||
match (config.include_commands, config.include_prompts) {
|
||||
(true, true) => commands.chain(ai_queries).collect(),
|
||||
(true, false) => commands.collect(),
|
||||
(false, true) => ai_queries.collect(),
|
||||
(false, false) => vec![],
|
||||
};
|
||||
|
||||
sort_and_dedupe_suggestions(suggestions, session_id, &all_live_session_ids)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,610 @@
|
||||
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 warp_core::settings::Setting;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::Expanded;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DispatchEventResult, DropTarget, Element, EventHandler, Flex, Hoverable, MainAxisSize,
|
||||
OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _,
|
||||
};
|
||||
|
||||
pub(super) const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_RADIUS: f32 = 8.;
|
||||
|
||||
const CLOUD_MODE_V2_TOP_ROW_GAP: f32 = 10.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_TOP_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_EDITOR_BOTTOM_PADDING: f32 = 8.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_BOTTOM_PADDING: f32 = 16.;
|
||||
|
||||
const CLOUD_MODE_V2_TOP_ROW_INNER_GAP: f32 = 4.;
|
||||
|
||||
const CLOUD_MODE_V2_INPUT_MIN_EDITOR_HEIGHT: f32 = 80.;
|
||||
|
||||
/// Horizontal gutter applied symmetrically on both sides of the V2 cloud-mode
|
||||
/// composing UI so the floating input has matching breathing room on the left
|
||||
/// and right at narrow widths.
|
||||
const CLOUD_MODE_V2_HORIZONTAL_GUTTER: f32 = 16.;
|
||||
|
||||
// Top padding above the attachment chips row inside the V2 input container.
|
||||
const CLOUD_MODE_V2_CHIPS_ROW_TOP_PADDING: f32 = 4.;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Renders the input when there is an active `AgentView`.
|
||||
///
|
||||
/// Only used when `FeatureFlag::AgentView` is enabled.
|
||||
pub(super) fn render_agent_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if self.is_cloud_mode_input_v2_composing(app) {
|
||||
return self.render_cloud_mode_v2_composing_input(app);
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, /*is_compact_mode=*/ false)
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(banner)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI)
|
||||
{
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let show_harness_row = FeatureFlag::CloudMode.is_enabled()
|
||||
&& FeatureFlag::AgentHarness.is_enabled()
|
||||
&& self
|
||||
.ambient_agent_view_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(),
|
||||
);
|
||||
}
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
column.add_child(
|
||||
Container::new(self.render_input_box(/*show_vim_status=*/ false, appearance, app))
|
||||
.with_margin_top(
|
||||
terminal_spacing.prompt_to_editor_padding
|
||||
* spacing::UDI_PROMPT_BOTTOM_PADDING_FACTOR,
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
column.add_child(
|
||||
SavePosition::new(
|
||||
ChildView::new(&self.agent_input_footer).finish(),
|
||||
&self.prompt_save_position_id(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false,
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let border_color = 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
|
||||
// classification is necessarily stale (intentionally inherited from the last
|
||||
// classification prior to clearing the input)
|
||||
&& (!self.editor.as_ref(app).is_empty(app)
|
||||
|| self.ai_input_model.as_ref(app).is_input_type_locked())
|
||||
{
|
||||
appearance.theme().ansi_fg_blue()
|
||||
} else {
|
||||
styles::default_border_color(appearance.theme())
|
||||
};
|
||||
|
||||
let mut input = Container::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_border(Border::top(1.).with_border_color(border_color))
|
||||
.with_padding_bottom(4.);
|
||||
|
||||
if self.agent_view_controller.as_ref(app).is_inline() {
|
||||
input = input.with_background(agent_view_bg_fill(app));
|
||||
}
|
||||
|
||||
let input = input.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_model_selector_view).finish());
|
||||
} else if FeatureFlag::InlineProfileSelector.is_enabled()
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.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() {
|
||||
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());
|
||||
} else if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& self.suggestions_mode_model.as_ref(app).is_skill_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_user_query_menu() {
|
||||
column.add_child(ChildView::new(&self.user_query_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_rewind_menu() {
|
||||
column.add_child(ChildView::new(&self.rewind_menu_view).finish());
|
||||
} else if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_repos_menu() {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_plan_menu() {
|
||||
column.add_child(ChildView::new(&self.inline_plan_menu_view).finish());
|
||||
}
|
||||
|
||||
if self
|
||||
.agent_shortcut_view_model
|
||||
.as_ref(app)
|
||||
.is_shortcut_view_open()
|
||||
{
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let (is_cloud_agent, has_submitted_first_prompt) =
|
||||
match agent_view_controller.agent_view_state() {
|
||||
AgentViewState::Active {
|
||||
conversation_id,
|
||||
origin,
|
||||
..
|
||||
} => {
|
||||
let is_cloud_agent = origin.is_cloud_agent();
|
||||
let has_submitted_first_prompt = if is_cloud_agent {
|
||||
BlocklistAIHistoryModel::as_ref(app)
|
||||
.conversation(conversation_id)
|
||||
.is_some_and(|c| c.initial_user_query().is_some())
|
||||
} else {
|
||||
true
|
||||
};
|
||||
(is_cloud_agent, has_submitted_first_prompt)
|
||||
}
|
||||
// When inactive, show all shortcuts (treat as not-cloud and not in the zero-state).
|
||||
AgentViewState::Inactive => (false, true),
|
||||
};
|
||||
|
||||
column.add_child(render_agent_shortcuts_view(
|
||||
AgentShortcutsViewContext {
|
||||
is_cloud_agent,
|
||||
has_submitted_first_prompt,
|
||||
},
|
||||
app,
|
||||
));
|
||||
}
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
|
||||
let mut outer_stack = Stack::new().with_constrain_absolute_children();
|
||||
outer_stack.add_child(column.finish());
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut outer_stack,
|
||||
&self.buy_credits_banner,
|
||||
self.is_pane_focused(app),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_composing_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
let model = self.model.lock();
|
||||
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
// Apply the V2 gutter symmetrically (left + right) so the floating
|
||||
// input keeps equal breathing room on both sides as the pane shrinks.
|
||||
// The shared `wrap_input_with_terminal_padding_and_focus_handler`
|
||||
// helper only pads the left, so V2 inlines its own padding + focus
|
||||
// handler instead of routing through it.
|
||||
let centered_content = Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(
|
||||
Expanded::new(1., self.render_cloud_mode_v2_content(appearance, app)).finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(CLOUD_MODE_V2_HORIZONTAL_GUTTER)
|
||||
.with_padding_right(CLOUD_MODE_V2_HORIZONTAL_GUTTER)
|
||||
.finish();
|
||||
|
||||
let centered_content = if self.is_active_session(app) {
|
||||
EventHandler::new(centered_content)
|
||||
.on_left_mouse_down(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
|
||||
ctx.dispatch_typed_action(InputAction::FocusInputBox);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
centered_content
|
||||
};
|
||||
|
||||
stack.add_child(centered_content);
|
||||
|
||||
if let Some(history_menu) = self.render_cloud_mode_v2_history_menu(app) {
|
||||
let prompt_position = self.prompt_save_position_id();
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(history_menu)
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
.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(-CLOUD_MODE_V2_TOP_ROW_GAP),
|
||||
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Bottom),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut outer_stack = Stack::new().with_constrain_absolute_children();
|
||||
outer_stack.add_child(input);
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut outer_stack,
|
||||
&self.buy_credits_banner,
|
||||
self.is_pane_focused(app),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
SavePosition::new(outer_stack.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_content(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.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));
|
||||
Align::new(
|
||||
ConstrainedBox::new(column.finish())
|
||||
.with_max_width(CLOUD_MODE_V2_MAX_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_history_menu(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if !self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let view = self.cloud_mode_v2_history_menu_view.as_ref()?;
|
||||
Some(ChildView::new(view).finish())
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_top_row(&self) -> 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());
|
||||
}
|
||||
row.add_child(ChildView::new(&self.harness_selector).finish());
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_cloud_mode_v2_input_container(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background = internal_colors::fg_overlay_1(theme);
|
||||
let border_color = internal_colors::neutral_2(theme);
|
||||
|
||||
let editor_with_min_height =
|
||||
ConstrainedBox::new(self.render_input_box(/*show_vim_status=*/ false, appearance, app))
|
||||
.with_min_height(CLOUD_MODE_V2_INPUT_MIN_EDITOR_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let mut editor_column = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Min);
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
let show_chips = FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI);
|
||||
if show_chips {
|
||||
if let Some(chips) = self.render_attachment_chips(appearance) {
|
||||
editor_column.add_child(
|
||||
Container::new(chips)
|
||||
.with_padding_top(CLOUD_MODE_V2_CHIPS_ROW_TOP_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
editor_column.add_child(
|
||||
Container::new(editor_with_min_height)
|
||||
.with_padding_top(CLOUD_MODE_V2_INPUT_TOP_PADDING)
|
||||
.with_padding_bottom(CLOUD_MODE_V2_INPUT_EDITOR_BOTTOM_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let editor = editor_column.finish();
|
||||
|
||||
let footer = Container::new(ChildView::new(&self.agent_input_footer).finish())
|
||||
.with_padding_bottom(CLOUD_MODE_V2_INPUT_BOTTOM_PADDING)
|
||||
.with_padding_left(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.with_padding_right(CLOUD_MODE_V2_INPUT_HORIZONTAL_PADDING)
|
||||
.finish();
|
||||
|
||||
let stacked = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(editor)
|
||||
.with_child(footer)
|
||||
.finish();
|
||||
|
||||
Container::new(SavePosition::new(stacked, &self.prompt_save_position_id()).finish())
|
||||
.with_background(background)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
CLOUD_MODE_V2_INPUT_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
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 mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
// Don't render status bar when agent has failed or is waiting for session
|
||||
let show_status_bar = ambient_agent_model.error_message().is_none()
|
||||
&& !ambient_agent_model.is_waiting_for_session();
|
||||
|
||||
let model = self.model.lock();
|
||||
maybe_add_buy_credits_banner(
|
||||
&mut stack,
|
||||
&self.buy_credits_banner,
|
||||
self.focus_handle.as_ref().is_none_or(|h| h.is_focused(app)),
|
||||
self.terminal_view_id,
|
||||
self.is_input_at_top(&model, app),
|
||||
app,
|
||||
);
|
||||
|
||||
let save_position =
|
||||
SavePosition::new(stack.finish(), &self.status_free_input_save_position_id()).finish();
|
||||
|
||||
let input = Hoverable::new(self.hoverable_handle.clone(), |_| save_position)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
if show_status_bar {
|
||||
column.add_child(ChildView::new(&self.agent_status_view).finish());
|
||||
}
|
||||
column.add_child(input);
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::WarpTheme;
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn default_border_color(theme: &WarpTheme) -> ColorU {
|
||||
blended_colors::neutral_2(theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use warpui::{Entity, ModelContext, ViewHandle};
|
||||
|
||||
use crate::editor::{self, EditorView, Point};
|
||||
|
||||
/// A 'replica' of the input buffer's contents that may be subscribed to without
|
||||
/// a strict dependency on the Input view itself.
|
||||
pub struct InputBufferModel {
|
||||
buffer_value: String,
|
||||
cursor_point: Option<Point>,
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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(..)
|
||||
| editor::Event::CtrlC { .. }
|
||||
| editor::Event::BufferReinitialized
|
||||
| editor::Event::SelectionChanged
|
||||
| editor::Event::BufferReplaced => {
|
||||
if let Some(editor) = editor_clone.upgrade(ctx) {
|
||||
let editor_ref = editor.as_ref(ctx);
|
||||
let mut value = editor_ref.buffer_text(ctx);
|
||||
let cursor_point = editor_ref.single_cursor_to_point(ctx);
|
||||
|
||||
me.cursor_point = cursor_point;
|
||||
if value != me.buffer_value {
|
||||
std::mem::swap(&mut value, &mut me.buffer_value);
|
||||
ctx.emit(InputBufferUpdateEvent {
|
||||
old_content: value,
|
||||
new_content: me.buffer_value.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
let editor_ref = editor.as_ref(ctx);
|
||||
Self {
|
||||
buffer_value: editor_ref.buffer_text(ctx),
|
||||
cursor_point: editor_ref.single_cursor_to_point(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_value(&self) -> &str {
|
||||
self.buffer_value.as_str()
|
||||
}
|
||||
|
||||
pub fn cursor_point(&self) -> Option<Point> {
|
||||
self.cursor_point
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputBufferUpdateEvent {
|
||||
pub old_content: String,
|
||||
pub new_content: String,
|
||||
}
|
||||
|
||||
impl Entity for InputBufferModel {
|
||||
type Event = InputBufferUpdateEvent;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, Container, DropTarget, Element, Empty, Flex,
|
||||
Hoverable, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
SavePosition, Stack,
|
||||
},
|
||||
AppContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use super::{should_render_prompt_using_editor_decorator_elements, Input, SubshellRenderState};
|
||||
|
||||
impl Input {
|
||||
/// Renders the classic input. This is used when the user has 'Honor PS1' enabled in settings,
|
||||
/// OR if `FeatureFlag::AgentView` is disabled and the user has 'Classic' input type selected
|
||||
/// in settings.
|
||||
pub(super) fn render_classic_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let model = self.model.lock();
|
||||
let should_render_prompt_using_editor_decorator_elements =
|
||||
should_render_prompt_using_editor_decorator_elements(
|
||||
false,
|
||||
&self.ai_input_model,
|
||||
&model,
|
||||
app,
|
||||
);
|
||||
|
||||
// We should likely rework this stack to not need to use `with_constrain_absolute_children`,
|
||||
// by reworking the positioning of the children to not depend on this.
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let mut prompt_row = Stack::new();
|
||||
|
||||
let (lprompt_top_area_option, rprompt_area_option);
|
||||
let mut prompt_top_padding_row = Stack::new();
|
||||
if should_render_prompt_using_editor_decorator_elements {
|
||||
// These are rendered as sections/notches in the EditorElement.
|
||||
lprompt_top_area_option = None;
|
||||
rprompt_area_option = None;
|
||||
|
||||
let terminal_spacing = TerminalSettings::as_ref(app)
|
||||
.terminal_input_spacing(appearance.line_height_ratio(), app);
|
||||
let default_prompt_top_padding = terminal_spacing.block_padding.padding_top
|
||||
* self.size_info(app).cell_height_px().as_f32()
|
||||
- get_input_box_top_border_width();
|
||||
let prompt_top_padding_element = Container::new(Empty::new().finish())
|
||||
.with_padding_top(default_prompt_top_padding)
|
||||
.finish();
|
||||
prompt_top_padding_row.add_child(prompt_top_padding_element);
|
||||
} else {
|
||||
let prompt_elements = self
|
||||
.prompt_render_helper
|
||||
.render_prompt_areas(&model, appearance, app);
|
||||
lprompt_top_area_option = prompt_elements.lprompt;
|
||||
rprompt_area_option = prompt_elements.rprompt;
|
||||
}
|
||||
|
||||
if !should_render_prompt_using_editor_decorator_elements {
|
||||
if let Some(lprompt_top_area) = lprompt_top_area_option {
|
||||
prompt_row.add_child(lprompt_top_area);
|
||||
}
|
||||
if let Some(rprompt_area) = rprompt_area_option {
|
||||
let block = &model.block_list().active_block();
|
||||
prompt_row.add_positioned_child(
|
||||
rprompt_area,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
block.rprompt_render_offset(&self.size_info(app)),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let vim_state = self.editor.as_ref(app).vim_state(app);
|
||||
let app_editor_settings = AppEditorSettings::as_ref(app);
|
||||
let show_vim_status = vim_state.is_some() && *app_editor_settings.vim_status_bar.value();
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
let is_compact_mode = matches!(
|
||||
TerminalSettings::as_ref(app).spacing_mode.value(),
|
||||
SpacingMode::Compact
|
||||
);
|
||||
let mut column = Flex::column();
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToBottom | InputMode::Waterfall) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(banner);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_children([prompt_top_padding_row.finish(), prompt_row.finish()]);
|
||||
|
||||
let ai_input_model = self.ai_input_model.as_ref(app);
|
||||
|
||||
if FeatureFlag::ImageAsContext.is_enabled()
|
||||
&& matches!(ai_input_model.input_type(), InputType::AI)
|
||||
&& !FeatureFlag::AgentView.is_enabled()
|
||||
{
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_padding_bottom(spacing::CLASSIC_PROMPT_ATTACH_IMAGES_BOTTOM_PADDING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(self.render_input_box(show_vim_status, appearance, app));
|
||||
|
||||
if should_show_terminal_input_message_bar(&model, app) {
|
||||
column.add_child(
|
||||
Clipped::new(ChildView::new(&self.terminal_input_message_bar).finish()).finish(),
|
||||
);
|
||||
} else if !(matches!(input_mode, InputMode::PinnedToTop)
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_menu_open())
|
||||
{
|
||||
column.add_child(
|
||||
Container::new(Flex::row().finish())
|
||||
.with_margin_bottom(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(input_mode, InputMode::PinnedToTop) {
|
||||
if let Some(banner) =
|
||||
self.render_input_banner(appearance, app, input_mode, is_compact_mode)
|
||||
{
|
||||
column.add_child(banner);
|
||||
}
|
||||
}
|
||||
|
||||
let subshell_flag = self.get_subshell_flag_render_state(&model, is_compact_mode, app);
|
||||
|
||||
let should_extend_flag = subshell_flag.is_some();
|
||||
|
||||
if should_extend_flag {
|
||||
let max_height = self.size_info(app).pane_height_px().as_f32();
|
||||
stack.add_positioned_child(
|
||||
render_subshell_flag_pole(max_height, theme.subshell_background()),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::ParentBySize,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(SubshellRenderState::Flag(command)) = subshell_flag {
|
||||
let flag = render_subshell_flag(
|
||||
command,
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
theme,
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
flag,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopLeft,
|
||||
ChildAnchor::TopLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !FeatureFlag::AgentView.is_enabled() {
|
||||
if let Some(vim_state) = vim_state.as_ref() {
|
||||
if show_vim_status {
|
||||
add_vim_status_to_stack(
|
||||
&mut stack, vim_state, appearance,
|
||||
false, // legacy doesn't use adjusted padding for vim status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false, // legacy uses full padding
|
||||
));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_voltron_open && self.is_pane_focused(app) {
|
||||
add_voltron_overlay(&mut stack, &self.voltron_view, menu_positioning);
|
||||
}
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
if let Some(token_description) = &self.command_x_ray_description {
|
||||
add_command_xray_overlay(
|
||||
self,
|
||||
&mut stack,
|
||||
token_description,
|
||||
appearance,
|
||||
menu_positioning,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
let input_mode = *InputModeSettings::as_ref(app).input_mode.value();
|
||||
|
||||
// When AgentView is enabled, match terminal-mode input behavior and only render the
|
||||
// divider adjacent to the status/message line when block dividers are enabled.
|
||||
let show_block_dividers = *BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
let should_render_divider = !FeatureFlag::AgentView.is_enabled() || show_block_dividers;
|
||||
|
||||
let border = match input_mode {
|
||||
InputMode::PinnedToBottom => Border::top(if should_render_divider {
|
||||
get_input_box_top_border_width()
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.with_border_fill(theme.outline()),
|
||||
InputMode::PinnedToTop => Border::bottom(if should_render_divider {
|
||||
get_input_box_top_border_width()
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.with_border_fill(theme.outline()),
|
||||
InputMode::Waterfall => Border::new(get_input_box_top_border_width())
|
||||
.with_sides(true, false, true, false)
|
||||
.with_border_fill(theme.outline()),
|
||||
};
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
Container::new(stack.finish()).with_border(border).finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = SavePosition::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
&self.status_free_input_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
let is_slash_commands = self.suggestions_mode_model.as_ref(app).is_slash_commands();
|
||||
let is_conversation_menu = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_conversation_menu();
|
||||
let is_model_selector = self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_model_selector();
|
||||
let is_prompts_menu = self.suggestions_mode_model.as_ref(app).is_prompts_menu();
|
||||
let is_skill_menu = self.suggestions_mode_model.as_ref(app).is_skill_menu();
|
||||
let is_inline_history_menu = FeatureFlag::InlineHistoryMenu.is_enabled()
|
||||
&& self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu();
|
||||
let is_repos_menu = FeatureFlag::InlineRepoMenu.is_enabled()
|
||||
&& self.suggestions_mode_model.as_ref(app).is_repos_menu();
|
||||
|
||||
match input_mode {
|
||||
InputMode::PinnedToBottom => {
|
||||
column.add_children(
|
||||
[
|
||||
if is_model_selector {
|
||||
Some(ChildView::new(&self.inline_model_selector_view).finish())
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
Some(input),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::PinnedToTop => {
|
||||
column.add_children(
|
||||
[
|
||||
Some(input),
|
||||
Some(ChildView::new(&self.agent_status_view).finish()),
|
||||
if is_model_selector {
|
||||
Some(ChildView::new(&self.inline_model_selector_view).finish())
|
||||
} else if is_slash_commands {
|
||||
Some(ChildView::new(&self.inline_slash_commands_view).finish())
|
||||
} else if is_prompts_menu {
|
||||
Some(ChildView::new(&self.inline_prompts_menu_view).finish())
|
||||
} else if is_conversation_menu {
|
||||
Some(ChildView::new(&self.inline_conversation_menu_view).finish())
|
||||
} else if FeatureFlag::ListSkills.is_enabled() && is_skill_menu {
|
||||
Some(ChildView::new(&self.inline_skill_selector_view).finish())
|
||||
} else if is_inline_history_menu {
|
||||
Some(ChildView::new(&self.inline_history_menu_view).finish())
|
||||
} else if is_repos_menu {
|
||||
Some(ChildView::new(&self.inline_repos_menu_view).finish())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
InputMode::Waterfall => {
|
||||
let should_render_below = self
|
||||
.inline_terminal_menu_positioner
|
||||
.as_ref(app)
|
||||
.should_render_inline_menu_below_input();
|
||||
|
||||
if is_slash_commands && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& !should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && !should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
|
||||
column.add_children([ChildView::new(&self.agent_status_view).finish(), input]);
|
||||
|
||||
if is_model_selector && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_model_selector_view).finish());
|
||||
} else if is_slash_commands && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if is_prompts_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if is_conversation_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_conversation_menu_view).finish());
|
||||
} else if FeatureFlag::ListSkills.is_enabled()
|
||||
&& is_skill_menu
|
||||
&& should_render_below
|
||||
{
|
||||
column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
} else if is_inline_history_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_history_menu_view).finish());
|
||||
} else if is_repos_menu && should_render_below {
|
||||
column.add_child(ChildView::new(&self.inline_repos_menu_view).finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SavePosition::new(column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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 warp_core::ui::{
|
||||
color::{contrast::MinimumAllowedContrast, ContrastingColor},
|
||||
theme::color::internal_colors,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Clipped, ConstrainedBox, Container, DispatchEventResult, DropTarget, Element,
|
||||
EventHandler, Flex, Hoverable, ParentElement, SavePosition, Stack,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, SingletonEntity as _, ViewContext,
|
||||
};
|
||||
|
||||
impl Input {
|
||||
/// Renders the CLI rich input (editor + CLI agent footer).
|
||||
pub(super) fn render_cli_agent_input(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let menu_positioning = self.menu_positioning(app);
|
||||
|
||||
let mut stack = Stack::new().with_constrain_absolute_children();
|
||||
|
||||
let input_box = Container::new(
|
||||
ConstrainedBox::new(Clipped::new(ChildView::new(&self.editor).finish()).finish())
|
||||
.with_max_height(CLI_AGENT_RICH_INPUT_EDITOR_MAX_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(CLI_AGENT_RICH_INPUT_EDITOR_TOP_PADDING)
|
||||
.with_padding_right(*TERMINAL_VIEW_PADDING_LEFT)
|
||||
.with_padding_bottom(CLI_AGENT_RICH_INPUT_EDITOR_BOTTOM_PADDING)
|
||||
.finish();
|
||||
|
||||
let input_editor_save_position_id = self.editor_save_position_id();
|
||||
let editor_element = SavePosition::new(
|
||||
EventHandler::new(input_box)
|
||||
.on_right_mouse_down(move |ctx, _, position| {
|
||||
let input_rect = ctx
|
||||
.element_position_by_id(input_editor_save_position_id.clone())
|
||||
.expect("input editor position id should be saved");
|
||||
let offset_position = position - input_rect.origin();
|
||||
ctx.dispatch_typed_action(TerminalAction::OpenInputContextMenu {
|
||||
position: offset_position,
|
||||
});
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish(),
|
||||
&self.editor_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut column = Flex::column();
|
||||
|
||||
// Render attachment chips (e.g. pasted screenshots) above the editor,
|
||||
// matching the pattern used by the agent view input in agent.rs.
|
||||
if FeatureFlag::ImageAsContext.is_enabled() {
|
||||
if let Some(images) = self.render_attachment_chips(appearance) {
|
||||
column.add_child(
|
||||
Container::new(images)
|
||||
.with_margin_top(spacing::UDI_CHIP_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
column.add_child(editor_element);
|
||||
column.add_child(
|
||||
SavePosition::new(
|
||||
Container::new(ChildView::new(&self.agent_input_footer).finish())
|
||||
.with_padding_right(*TERMINAL_VIEW_PADDING_LEFT)
|
||||
.finish(),
|
||||
&self.prompt_save_position_id(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
|
||||
self.is_active_session(app),
|
||||
column.finish(),
|
||||
false,
|
||||
));
|
||||
|
||||
if self.is_pane_focused(app) {
|
||||
add_input_suggestions_overlays(self, &mut stack, appearance, menu_positioning, app);
|
||||
}
|
||||
|
||||
let mut input_container = Container::new(stack.finish()).with_border(
|
||||
Border::top(1.0).with_border_fill(internal_colors::fg_overlay_2(appearance.theme())),
|
||||
);
|
||||
|
||||
// When an alt screen CLI agent (e.g. OpenCode) is running, match
|
||||
// the rich input background to the alt screen so it blends in.
|
||||
{
|
||||
let terminal_model = self.model.lock();
|
||||
if terminal_model.is_alt_screen_active() {
|
||||
if let Some(bg_color) = terminal_model.alt_screen().inferred_bg_color() {
|
||||
input_container = input_container.with_background(bg_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let drop_target = DropTarget::new(
|
||||
input_container.finish(),
|
||||
InputDropTargetData::new(self.weak_view_handle.clone()),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let input = SavePosition::new(
|
||||
Hoverable::new(self.hoverable_handle.clone(), |_| drop_target)
|
||||
.on_hover(|is_hovered, ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(InputAction::SetUDIHovered(is_hovered));
|
||||
})
|
||||
.on_middle_click(|ctx, _app, _position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::MiddleClickOnInput)
|
||||
})
|
||||
.finish(),
|
||||
&self.status_free_input_save_position_id(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Render inline menus (slash commands, prompts, skills) above the input,
|
||||
// matching the pattern used by the agent view input in agent.rs.
|
||||
// These must be outside the Hoverable so that mouse events on the menu
|
||||
// don't trigger SetUDIHovered, which would cause layout jitter.
|
||||
let mut outer_column = Flex::column();
|
||||
if self.suggestions_mode_model.as_ref(app).is_slash_commands() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_slash_commands_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_prompts_menu() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_prompts_menu_view).finish());
|
||||
} else if self.suggestions_mode_model.as_ref(app).is_skill_menu() {
|
||||
outer_column.add_child(ChildView::new(&self.inline_skill_selector_view).finish());
|
||||
}
|
||||
outer_column.add_child(input);
|
||||
|
||||
SavePosition::new(outer_column.finish(), &self.save_position_id()).finish()
|
||||
}
|
||||
|
||||
/// Keep the rich input editor's text colors legible when it's rendered on
|
||||
/// top of an alt-screen CLI agent's inferred background (e.g. OpenCode),
|
||||
/// which does not respect the Warp theme. When no alt-screen-backed CLI
|
||||
/// agent rich input is active, restores the theme default text colors.
|
||||
///
|
||||
/// This mirrors the contrast-adjustment pattern used for the use-agent
|
||||
/// toolbar button text (see `AgentFooterButtonTheme::text_color`) and the
|
||||
/// CLI agent brand icon in `AgentInputFooter::render_cli_mode_footer`.
|
||||
pub(super) fn update_cli_agent_editor_text_colors(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let default_colors = TextColors::from_appearance(appearance);
|
||||
|
||||
// Only override while the CLI agent rich input is actually open - the
|
||||
// same editor is reused for the normal terminal input and for other
|
||||
// modes (AI, shared sessions), and those shouldn't see the override.
|
||||
let rich_input_open =
|
||||
CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id);
|
||||
|
||||
let alt_screen_bg = if rich_input_open {
|
||||
let terminal_model = self.model.lock();
|
||||
terminal_model
|
||||
.is_alt_screen_active()
|
||||
.then(|| terminal_model.alt_screen().inferred_bg_color())
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_colors = match alt_screen_bg {
|
||||
Some(bg) => TextColors {
|
||||
default_color: default_colors
|
||||
.default_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
disabled_color: default_colors
|
||||
.disabled_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
hint_color: default_colors
|
||||
.hint_color
|
||||
.on_background(bg.into(), MinimumAllowedContrast::Text),
|
||||
},
|
||||
None => default_colors,
|
||||
};
|
||||
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_text_colors(text_colors, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Align, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Radius, Text,
|
||||
};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_history::{
|
||||
AcceptHistoryItem, HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView,
|
||||
};
|
||||
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::model::session::active_session::ActiveSession;
|
||||
|
||||
const MENU_MAX_HEIGHT: f32 = 168.;
|
||||
|
||||
const MENU_VERTICAL_PADDING: f32 = 4.;
|
||||
|
||||
const MENU_CORNER_RADIUS: f32 = 6.;
|
||||
|
||||
const DROP_SHADOW_COLOR: ColorU = ColorU {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 77,
|
||||
};
|
||||
|
||||
pub struct CloudModeV2HistoryMenuView {
|
||||
inner: ViewHandle<InlineHistoryMenuView>,
|
||||
}
|
||||
|
||||
impl CloudModeV2HistoryMenuView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
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(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&inner, |_, _, event, ctx| {
|
||||
ctx.emit(event.clone());
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.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));
|
||||
}
|
||||
|
||||
pub fn accept_selected(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.inner.update(ctx, |v, ctx| v.accept_selected_item(ctx));
|
||||
}
|
||||
|
||||
pub fn has_selection(&self, app: &AppContext) -> bool {
|
||||
self.inner
|
||||
.as_ref(app)
|
||||
.model()
|
||||
.as_ref(app)
|
||||
.selected_item()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn selected_query_text(&self, app: &AppContext) -> Option<String> {
|
||||
match self.inner.as_ref(app).model().as_ref(app).selected_item()? {
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text.clone()),
|
||||
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::Conversation { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudModeV2HistoryMenuView {
|
||||
type Event = InlineHistoryMenuEvent;
|
||||
}
|
||||
|
||||
impl View for CloudModeV2HistoryMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"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)
|
||||
.finish();
|
||||
|
||||
let padded = Container::new(constrained)
|
||||
.with_padding_top(MENU_VERTICAL_PADDING)
|
||||
.with_padding_bottom(MENU_VERTICAL_PADDING)
|
||||
.finish();
|
||||
|
||||
Container::new(padded)
|
||||
.with_background(background)
|
||||
.with_border(Border::all(1.).with_border_color(border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MENU_CORNER_RADIUS)))
|
||||
.with_drop_shadow(DropShadow::new_with_standard_offset_and_spread(
|
||||
DROP_SHADOW_COLOR,
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
use vim::vim::{VimMode, VimState};
|
||||
use warp_completer::completer::Description;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
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,
|
||||
};
|
||||
|
||||
/// Whether the terminal input message bar should be shown.
|
||||
///
|
||||
/// The message bar is hidden when AI is disabled, the user has turned it off in settings,
|
||||
/// or the session is a shared ambient agent session.
|
||||
pub(super) fn should_show_terminal_input_message_bar(
|
||||
model: &TerminalModel,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
FeatureFlag::AgentView.is_enabled()
|
||||
&& !FeatureFlag::AgentViewPromptChip.is_enabled()
|
||||
&& InputSettings::as_ref(app).is_terminal_input_message_bar_enabled()
|
||||
&& AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
&& !model.is_shared_ambient_agent_session()
|
||||
}
|
||||
|
||||
/// Renders vim status bar
|
||||
/// Used by: agent.rs, terminal.rs, universal.rs, legacy.rs
|
||||
pub(super) fn render_vim_status(vim_state: &VimState, appearance: &Appearance) -> Container {
|
||||
let theme = appearance.theme();
|
||||
let ansi_colors = theme.terminal_colors().bright;
|
||||
let icon = match vim_state.mode {
|
||||
VimMode::Normal => Icon::VimNormalMode.to_warpui_icon(ansi_colors.green.into()),
|
||||
VimMode::Insert => {
|
||||
use crate::themes::theme::Blend;
|
||||
Icon::VimInsertMode.to_warpui_icon(
|
||||
theme
|
||||
.background()
|
||||
.blend(&theme.foreground().with_opacity(50)),
|
||||
)
|
||||
}
|
||||
VimMode::Visual(_) => Icon::VimVisualMode.to_warpui_icon(ansi_colors.blue.into()),
|
||||
VimMode::Replace => Icon::VimReplaceMode.to_warpui_icon(ansi_colors.red.into()),
|
||||
};
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_children([
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
vim_state.showcmd.to_owned(),
|
||||
appearance.monospace_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.with_margin_bottom(2.)
|
||||
.finish(),
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_width(12.)
|
||||
.with_height(12.)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.with_margin_bottom(4.)
|
||||
}
|
||||
|
||||
/// Renders the vim status indicator in the bottom right corner of the given stack.
|
||||
pub(super) fn add_vim_status_to_stack(
|
||||
stack: &mut Stack,
|
||||
vim_state: &VimState,
|
||||
appearance: &Appearance,
|
||||
use_adjusted_padding: bool,
|
||||
) {
|
||||
let terminal_padding = if use_adjusted_padding {
|
||||
*PADDING_LEFT / 1.5
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
let status_bar = render_vim_status(vim_state, appearance)
|
||||
.with_padding_bottom(4.)
|
||||
.with_uniform_margin(terminal_padding)
|
||||
.finish();
|
||||
stack.add_positioned_child(
|
||||
status_bar,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0.0, 0.0),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomRight,
|
||||
ChildAnchor::BottomRight,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wraps the given column, assumed to represent the full input content, with appropriate
|
||||
/// left padding to be consistent with the terminal content, as well as an event handler to
|
||||
/// focus the input view when clicked.
|
||||
pub(super) fn wrap_input_with_terminal_padding_and_focus_handler(
|
||||
is_active_session: bool,
|
||||
column: Box<dyn Element>,
|
||||
use_adjusted_padding: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let terminal_padding = if use_adjusted_padding {
|
||||
*PADDING_LEFT / 1.5
|
||||
} else {
|
||||
*PADDING_LEFT
|
||||
};
|
||||
|
||||
if is_active_session {
|
||||
let mut flex_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::End);
|
||||
|
||||
flex_row.add_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(column)
|
||||
.with_padding_left(terminal_padding)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
EventHandler::new(flex_row.finish())
|
||||
.on_left_mouse_down(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ClearSelectionsWhenShellMode);
|
||||
ctx.dispatch_typed_action(InputAction::FocusInputBox);
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(column)
|
||||
.with_padding_left(terminal_padding)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the selected workflow info overlay over the input.
|
||||
pub(super) fn add_workflow_info_overlay(
|
||||
stack: &mut Stack,
|
||||
selected_workflow_state: &super::SelectedWorkflowState,
|
||||
pane_height_px: f32,
|
||||
menu_positioning: MenuPositioning,
|
||||
) {
|
||||
let workflows_info_view =
|
||||
Container::new(ChildView::new(&selected_workflow_state.more_info_view).finish())
|
||||
.with_margin_left(16.)
|
||||
.with_margin_right(16.)
|
||||
.finish();
|
||||
|
||||
stack.add_positioned_overlay_child(
|
||||
ConstrainedBox::new(workflows_info_view)
|
||||
.with_max_height(pane_height_px * 0.35)
|
||||
.finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.workflows_info_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the voltron overlay over the input.
|
||||
pub(super) fn add_voltron_overlay(
|
||||
stack: &mut Stack,
|
||||
voltron_view: &ViewHandle<crate::voltron::Voltron>,
|
||||
menu_positioning: MenuPositioning,
|
||||
) {
|
||||
stack.add_positioned_overlay_child(
|
||||
ChildView::new(voltron_view).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
menu_positioning.voltron_offset(),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
menu_positioning.voltron_parent_anchor(),
|
||||
menu_positioning.voltron_child_anchor(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the appropriate input suggestions overlay over the input, bsaed on the current input
|
||||
/// suggestions mode (if any).
|
||||
pub(super) fn add_input_suggestions_overlays(
|
||||
input: &Input,
|
||||
stack: &mut Stack,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
app: &AppContext,
|
||||
) {
|
||||
match input.suggestions_mode_model().as_ref(app).mode() {
|
||||
InputSuggestionsMode::HistoryUp { .. } => {
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_history_up_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_parent(
|
||||
ParentOffsetBounds::Unbounded,
|
||||
menu_positioning.history_y_offset(),
|
||||
menu_positioning.history_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::CompletionSuggestions { menu_position, .. } => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_completion_suggestions_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::StaticWorkflowEnumSuggestions { menu_position, .. } => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_workflow_enum_suggestions_menu(appearance, menu_positioning),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::DynamicWorkflowEnumSuggestions {
|
||||
menu_position,
|
||||
dynamic_enum_status,
|
||||
command,
|
||||
suggestions,
|
||||
..
|
||||
} => {
|
||||
let relative_position_id = menu_position.to_position_id(input.editor.id());
|
||||
stack.add_positioned_overlay_child(
|
||||
input.render_dynamic_workflow_enum_menu(
|
||||
appearance,
|
||||
menu_positioning,
|
||||
command.clone(),
|
||||
dynamic_enum_status.clone(),
|
||||
suggestions,
|
||||
),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&relative_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
OffsetType::Pixel(0.),
|
||||
menu_positioning.completion_suggestions_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
InputSuggestionsMode::AIContextMenu { .. } => {
|
||||
input.render_ai_context_menu(stack, &menu_positioning, app);
|
||||
}
|
||||
// SlashCommandsMenu is rendered separately via inline_slash_commands_menu_view
|
||||
InputSuggestionsMode::SlashCommands => {}
|
||||
// Conversation menu is rendered separately via inline_conversation_menu_view
|
||||
InputSuggestionsMode::ConversationMenu => {}
|
||||
// Model selector is rendered separately via inline_model_selector_view
|
||||
InputSuggestionsMode::ModelSelector => {}
|
||||
// Profile selector is rendered separately via inline_profile_selector_view
|
||||
InputSuggestionsMode::ProfileSelector => {}
|
||||
// Prompts menu is rendered separately via inline_prompts_menu_view
|
||||
InputSuggestionsMode::PromptsMenu => {}
|
||||
// Skill menu is rendered separately via inline_skill_selector_view
|
||||
InputSuggestionsMode::SkillMenu => {}
|
||||
// User query menu is rendered separately via user_query_menu_view
|
||||
InputSuggestionsMode::UserQueryMenu { .. } => {}
|
||||
// Inline history menu is rendered separately via inline_history_menu_view
|
||||
InputSuggestionsMode::InlineHistoryMenu { .. } => {}
|
||||
// Repos menu is rendered separately via inline_repos_menu_view
|
||||
InputSuggestionsMode::IndexedReposMenu => {}
|
||||
// Plan menu is rendered separately via inline_plan_menu_view
|
||||
InputSuggestionsMode::PlanMenu { .. } => {}
|
||||
InputSuggestionsMode::Closed => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the command xray overlay on the input using the command x ray-specific position id.
|
||||
pub(super) fn add_command_xray_overlay(
|
||||
input: &Input,
|
||||
stack: &mut Stack,
|
||||
token_description: &Arc<Description>,
|
||||
appearance: &Appearance,
|
||||
menu_positioning: MenuPositioning,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let command_x_ray_position_id = format!("editor:command_x_ray_{}", input.editor.id());
|
||||
let line_height = input
|
||||
.editor
|
||||
.as_ref(app)
|
||||
.line_height(app.font_cache(), appearance);
|
||||
let offset = match menu_positioning {
|
||||
MenuPositioning::AboveInputBox => OffsetType::Pixel(0.),
|
||||
MenuPositioning::BelowInputBox => OffsetType::Pixel(line_height),
|
||||
};
|
||||
stack.add_positioned_overlay_child(
|
||||
render_command_token_description(token_description, appearance),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&command_x_ray_position_id,
|
||||
PositionedElementOffsetBounds::ParentByPosition,
|
||||
OffsetType::Pixel(0.),
|
||||
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&command_x_ray_position_id,
|
||||
PositionedElementOffsetBounds::Unbounded,
|
||||
offset,
|
||||
menu_positioning.command_xray_y_anchor(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_command_token_description(
|
||||
description: &Arc<Description>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Append an ellipsis to the description if the token has more characters than the max
|
||||
// number of characters that are allowed.
|
||||
const MAX_XRAY_LABEL_CHARS: usize = 16;
|
||||
const TOKEN_DESCRIPTION_PADDING: f32 = 12.;
|
||||
const TOKEN_DESCRIPTION_MARGIN: f32 = 10.;
|
||||
const TOKEN_DESCRIPTION_WIDTH: f32 = 240.;
|
||||
const TOKEN_LABEL_HORIZONTAL_PADDING: f32 = 8.;
|
||||
const TOKEN_LABEL_VERTICAL_PADDING: f32 = 4.;
|
||||
|
||||
let truncated_label = match description
|
||||
.token
|
||||
.item
|
||||
.char_indices()
|
||||
.nth(MAX_XRAY_LABEL_CHARS)
|
||||
{
|
||||
None => description.token.item.clone(),
|
||||
Some((byte_index, _)) => format!("{}...", &description.token[..byte_index]),
|
||||
};
|
||||
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let mut command_description = Flex::column().with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(truncated_label)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.monospace_font_family()),
|
||||
font_color: Some(theme.active_ui_text_color().into()),
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
font_weight: Some(Weight::Bold),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_top(2.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(description.suggestion_type.to_name().to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(theme.active_ui_text_color().into()),
|
||||
font_size: Some(appearance.monospace_font_size() * 0.75),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_background(theme.outline())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_margin_left(TOKEN_DESCRIPTION_MARGIN)
|
||||
.with_padding_left(TOKEN_LABEL_HORIZONTAL_PADDING)
|
||||
.with_padding_right(TOKEN_LABEL_HORIZONTAL_PADDING)
|
||||
.with_padding_top(TOKEN_LABEL_VERTICAL_PADDING)
|
||||
.with_padding_bottom(TOKEN_LABEL_VERTICAL_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(description_text) = description.description_text.clone() {
|
||||
command_description.add_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.paragraph(description_text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(theme.sub_text_color(theme.surface_2()).into()),
|
||||
font_size: Some(appearance.monospace_font_size() * 0.9),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(TOKEN_DESCRIPTION_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(command_description.finish())
|
||||
.with_uniform_padding(TOKEN_DESCRIPTION_PADDING)
|
||||
.with_margin_bottom(TOKEN_DESCRIPTION_MARGIN)
|
||||
.with_border(Border::all(1.).with_border_fill(theme.split_pane_border_color()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background_color(theme.surface_2().into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(TOKEN_DESCRIPTION_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Conditionally adds the "buy credits" banner overlay.
|
||||
/// The overlay only is shown if all of the following is true:
|
||||
/// - The user is on a team that can purchase addon credits
|
||||
/// - The user is out of credits (or at their auto-reload limit)
|
||||
/// - The input is focused
|
||||
/// - There is not a BYO API key for the current model
|
||||
pub(super) fn maybe_add_buy_credits_banner(
|
||||
stack: &mut Stack,
|
||||
buy_credits_banner: &ViewHandle<BuyCreditsBanner>,
|
||||
is_focused: bool,
|
||||
terminal_view_id: EntityId,
|
||||
is_input_at_top: bool,
|
||||
app: &AppContext,
|
||||
) {
|
||||
let can_purchase_addon_credits = UserWorkspaces::as_ref(app)
|
||||
.current_team()
|
||||
.and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy)
|
||||
.is_some_and(|policy| policy.enabled);
|
||||
|
||||
// Show buy credits banner if billing policy allows purchasing, input is focused,
|
||||
// and either:
|
||||
// 1. OutOfCredits: for users that are not auto-reload enabled
|
||||
// 2. MonthlyLimitReached: Auto-reload enabled and is blocked by monthly limit
|
||||
let ai_request_usage = AIRequestUsageModel::as_ref(app);
|
||||
let should_show_banner = !matches!(
|
||||
ai_request_usage.compute_buy_addon_credits_banner_display_state(app),
|
||||
BuyCreditsBannerDisplayState::Hidden
|
||||
);
|
||||
let is_using_api_key_for_current_model = is_using_api_key_for_provider(
|
||||
&LLMPreferences::as_ref(app)
|
||||
.get_active_base_model(app, Some(terminal_view_id))
|
||||
.provider,
|
||||
app,
|
||||
);
|
||||
if can_purchase_addon_credits
|
||||
&& is_focused
|
||||
&& should_show_banner
|
||||
&& !is_using_api_key_for_current_model
|
||||
{
|
||||
add_buy_credits_banner_overlay(stack, buy_credits_banner, is_input_at_top);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds buy credits banner overlay to stack
|
||||
fn add_buy_credits_banner_overlay(
|
||||
stack: &mut Stack,
|
||||
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.)
|
||||
} else {
|
||||
(ParentAnchor::TopLeft, ChildAnchor::BottomLeft, -8.)
|
||||
};
|
||||
stack.add_positioned_child(
|
||||
ChildView::new(buy_credits_banner).finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., y_offset),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
parent_anchor,
|
||||
child_anchor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Data source for the inline conversation menu.
|
||||
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, ModelHandle};
|
||||
|
||||
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;
|
||||
|
||||
pub struct ConversationMenuDataSource {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
}
|
||||
|
||||
impl ConversationMenuDataSource {
|
||||
pub fn new(
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent_view_controller,
|
||||
active_session,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ConversationMenuDataSource {
|
||||
type Action = AcceptConversation;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let conversation_navigation_data = ConversationNavigationData::all_conversations(app);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
let active_conversation_id = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id();
|
||||
|
||||
let filter_by_cwd = query
|
||||
.filters
|
||||
.contains(&QueryFilter::CurrentDirectoryConversations);
|
||||
let session_pwd = if filter_by_cwd {
|
||||
self.active_session
|
||||
.as_ref(app)
|
||||
.current_working_directory()
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When the "Current Directory" filter is active, include only conversations
|
||||
// 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 {
|
||||
if !filter_by_cwd {
|
||||
return true;
|
||||
}
|
||||
let Some(session_pwd) = session_pwd.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
data.latest_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)
|
||||
})
|
||||
};
|
||||
|
||||
if query_text.is_empty() {
|
||||
// By default, show 50 most recent conversations in the list.
|
||||
const DEFAULT_RESULT_COUNT: usize = 50;
|
||||
|
||||
// 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
|
||||
.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))
|
||||
.take(DEFAULT_RESULT_COUNT)
|
||||
.map(|navigation_data| {
|
||||
QueryResult::from(ConversationSearchItem::new(navigation_data, app))
|
||||
})
|
||||
.rev()
|
||||
.collect())
|
||||
} else {
|
||||
let mut search_results = conversation_navigation_data
|
||||
.into_iter()
|
||||
.filter_map(|navigation_data| {
|
||||
if Some(navigation_data.id()) == active_conversation_id {
|
||||
// Don't show the currently open conversation, that's redundant.
|
||||
return None;
|
||||
}
|
||||
if !matches_directory(&navigation_data) {
|
||||
return None;
|
||||
}
|
||||
let match_result = fuzzy_match::match_indices_case_insensitive(
|
||||
&navigation_data.title,
|
||||
&query_text,
|
||||
)?;
|
||||
|
||||
// 25 is arbitrary.
|
||||
if match_result.score < 25 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
ConversationSearchItem::new(navigation_data, app)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.sorted_by(|a, b| b.score().cmp(&a.score()))
|
||||
.collect_vec();
|
||||
|
||||
// This is basically here so the app doesn't choke.
|
||||
const MAX_SEARCH_RESULTS: usize = 500;
|
||||
|
||||
search_results.truncate(MAX_SEARCH_RESULTS);
|
||||
Ok(search_results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ConversationMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Inline conversation menu for selecting AI conversations, enabled
|
||||
//! when `FeatureFlag::AgentView` is enabled.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlineConversationMenuEvent, InlineConversationMenuView};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{keymap::Keystroke, SingletonEntity};
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
/// Tab identifiers for the inline conversation menu.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InlineConversationMenuTab {
|
||||
/// Show all conversations.
|
||||
All,
|
||||
/// Show only conversations whose most recent directory matches the session's CWD.
|
||||
CurrentDirectory,
|
||||
}
|
||||
|
||||
/// Action emitted when enter is hit on a conversation the inline conversation menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptConversation {
|
||||
pub navigation_data: ConversationNavigationData,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptConversation {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ConversationMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
} = args;
|
||||
|
||||
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 text = if is_active {
|
||||
" go to conversation"
|
||||
} else {
|
||||
" continue in this pane"
|
||||
};
|
||||
|
||||
let navigation_data = data.clone();
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(text),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: AcceptConversation {
|
||||
navigation_data: navigation_data.clone(),
|
||||
},
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept.clone(),
|
||||
));
|
||||
} else {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let disabled_color = theme.disabled_text_color(theme.background()).into_solid();
|
||||
items.extend([
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: Some(disabled_color),
|
||||
background_color: Some(ColorU::transparent_black()),
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: " continue in this pane".into(),
|
||||
color: Some(disabled_color),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! SearchItem implementation for conversation menu items.
|
||||
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
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::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::conversations::AcceptConversation;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
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,
|
||||
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());
|
||||
|
||||
Self {
|
||||
navigation_data,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
conversation_status,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ConversationSearchItem {
|
||||
type Action = AcceptConversation;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
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_warpui_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()
|
||||
}
|
||||
};
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
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 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 full_text = if should_show_suffix {
|
||||
format!("{title}{secondary_suffix}")
|
||||
} else {
|
||||
title.clone()
|
||||
};
|
||||
|
||||
let mut name_text = Text::new_inline(full_text, appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if should_show_suffix {
|
||||
let secondary_range = title.len()..(title.len() + secondary_suffix.len());
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new()
|
||||
.with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
})
|
||||
.with_foreground_color(secondary_text_color.into()),
|
||||
secondary_range.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., name_text.finish()).finish());
|
||||
|
||||
// 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()),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptConversation {
|
||||
navigation_data: self.navigation_data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Conversation: {}", self.navigation_data.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Inline conversation menu view for selecting AI conversations.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
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;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::conversations::data_source::ConversationMenuDataSource;
|
||||
use crate::terminal::input::conversations::{AcceptConversation, InlineConversationMenuTab};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuPositioner, InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
/// Events emitted by InlineConversationMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineConversationMenuEvent {
|
||||
/// User 'accepted' a conversation (hit enter).
|
||||
NavigateToConversation {
|
||||
conversation_navigation_data: Box<ConversationNavigationData>,
|
||||
},
|
||||
/// User dismissed the menu (escape or click).
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
static TAB_CONFIGS: LazyLock<Vec<InlineMenuTabConfig<InlineConversationMenuTab>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut configs = vec![InlineMenuTabConfig {
|
||||
id: InlineConversationMenuTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
}];
|
||||
if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
configs.push(InlineMenuTabConfig {
|
||||
id: InlineConversationMenuTab::CurrentDirectory,
|
||||
label: "Current Directory".to_string(),
|
||||
filters: HashSet::from([QueryFilter::CurrentDirectoryConversations]),
|
||||
});
|
||||
}
|
||||
configs
|
||||
});
|
||||
|
||||
pub struct InlineConversationMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptConversation, InlineConversationMenuTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptConversation>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
}
|
||||
|
||||
impl InlineConversationMenuView {
|
||||
pub fn new(
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| {
|
||||
ConversationMenuDataSource::new(agent_view_controller.clone(), active_session)
|
||||
});
|
||||
|
||||
let tab_configs = TAB_CONFIGS.clone();
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptConversation>::new();
|
||||
mixer.add_sync_source(data_source, [QueryFilter::CurrentDirectoryConversations]);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
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()),
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => (),
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineConversationMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if input_suggestions_model.as_ref(ctx).is_conversation_menu() {
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me
|
||||
.input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
let InputBufferUpdateEvent { new_content: _, .. } = event;
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&active_agent_views_model, |me, _, _, ctx| {
|
||||
if me
|
||||
.input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.is_conversation_menu()
|
||||
{
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self
|
||||
.menu_view
|
||||
.as_ref(ctx)
|
||||
.model()
|
||||
.as_ref(ctx)
|
||||
.active_tab_filters();
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: self
|
||||
.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.to_owned(),
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineConversationMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineConversationMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineConversationMenuView {
|
||||
type Event = InlineConversationMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! 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 settings::Setting as _;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity, ViewContext};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::{EmptyCompletionContext, SessionContext},
|
||||
editor::TextStyleOperation,
|
||||
settings::InputSettings,
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
};
|
||||
|
||||
use super::Input;
|
||||
|
||||
pub use warp_completer::{
|
||||
completer::SuggestionTypeName, util::parse_current_commands_and_tokens, ParsedTokenData,
|
||||
ParsedTokensSnapshot,
|
||||
};
|
||||
|
||||
/// Options to enable/disable command decoration and/or AI input background tasks spawned on input
|
||||
/// edits.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct InputBackgroundJobOptions {
|
||||
command_decoration: bool,
|
||||
ai_input_detection: bool,
|
||||
}
|
||||
|
||||
impl InputBackgroundJobOptions {
|
||||
pub fn with_command_decoration(mut self) -> Self {
|
||||
self.command_decoration = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_ai_input_detection(mut self) -> Self {
|
||||
self.ai_input_detection = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns `true` if there are no input background jobs to run. Returns `false` if there is at
|
||||
/// least one job to run.
|
||||
fn no_jobs_to_run(self) -> bool {
|
||||
!self.command_decoration && !self.ai_input_detection
|
||||
}
|
||||
}
|
||||
|
||||
// Characters that will make us ignore commands for error underlining - largely
|
||||
// a temporary solution till our parser improves (to handle special edge cases).
|
||||
// Note that some of these are redundant since we split on tokens such as "&"
|
||||
// but including them to be defensive.
|
||||
// TODO: Remove "," once we differentiate between brace expansion and grouped commands
|
||||
// at the parsing level.
|
||||
const INVALID_SYMBOLS_COMMAND_ERROR_UNDERLINING: [char; 22] = [
|
||||
'~', '`', '#', '$', '&', '*', '(', ')', '\\', '|', '[', ']', '{', '}', ';', '\'', '\"', '<',
|
||||
'>', '?', '!', ',',
|
||||
];
|
||||
|
||||
enum CompletionSessionContext {
|
||||
Session(SessionContext),
|
||||
Empty(EmptyCompletionContext),
|
||||
}
|
||||
|
||||
/// Returns boolean indicating whether we should attempt to red underline
|
||||
/// the command or not (this is a stop-gap since our parser doesn't cover
|
||||
/// all the edge cases for commands currently e.g. "!!"). We don't want
|
||||
/// to incorrectly red underline a valid command. In other words,
|
||||
/// we would rather miss red underlining an invalid command compared to
|
||||
/// incorrectly red underlining a valid command.
|
||||
fn valid_command_for_error_underline(command: &str) -> bool {
|
||||
command
|
||||
.chars()
|
||||
.all(|x| !INVALID_SYMBOLS_COMMAND_ERROR_UNDERLINING.contains(&x))
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn completion_session_context_or_empty_context(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> CompletionSessionContext {
|
||||
self.completion_session_context(ctx)
|
||||
.map(CompletionSessionContext::Session)
|
||||
.unwrap_or_else(|| CompletionSessionContext::Empty(EmptyCompletionContext::new()))
|
||||
}
|
||||
|
||||
/// Whether or not any decorations should be computed and applied to the
|
||||
/// input text.
|
||||
pub fn should_apply_decorations(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
self.should_show_syntax_highlighting(ctx) || self.should_show_error_underlining(ctx)
|
||||
}
|
||||
|
||||
/// Whether or not syntax highlighting should be computed and applied to the
|
||||
/// input text.
|
||||
fn should_show_syntax_highlighting(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
*InputSettings::as_ref(ctx).syntax_highlighting.value()
|
||||
}
|
||||
|
||||
/// Whether or not error underlining should be computed and applied to the
|
||||
/// input text.
|
||||
fn should_show_error_underlining(&self, ctx: &ViewContext<Self>) -> bool {
|
||||
*InputSettings::as_ref(ctx).error_underlining.value()
|
||||
}
|
||||
|
||||
fn run_input_mode_detection(
|
||||
&self,
|
||||
completion_context: SessionContext,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(parsed_token) = self.last_parsed_tokens.clone() {
|
||||
let session_id = completion_context.session.id();
|
||||
self.ai_input_model.update(ctx, |ai_input_model, ctx| {
|
||||
ai_input_model.detect_and_set_input_type(
|
||||
parsed_token,
|
||||
completion_context,
|
||||
Some(session_id),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies background highlighting to slash command and skill command prefixes that should be
|
||||
/// syntax highlighted.
|
||||
fn apply_slash_command_prefix_highlighting(
|
||||
&mut self,
|
||||
buffer_text: &str,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
let highlighted_prefix_len = self
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.state()
|
||||
.command_prefix_highlight_len(buffer_text);
|
||||
|
||||
let Some(highlighted_prefix_len) = highlighted_prefix_len else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let theme = Appearance::as_ref(ctx).theme();
|
||||
let color = theme.ansi_fg_magenta();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
vec![CharOffset::from(0)..CharOffset::from(highlighted_prefix_len)],
|
||||
TextStyleOperation::default().set_syntax_color(color),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Computes information about the currently-entered command in a background
|
||||
/// task and then uses it to decorate the input, specifically applying
|
||||
/// styles for syntax highlighting and error underlining.
|
||||
/// Includes a short-circuit that lets us clear formatting and return without parsing the input.
|
||||
pub fn run_input_background_jobs(
|
||||
&mut self,
|
||||
mode: InputBackgroundJobOptions,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if mode.no_jobs_to_run() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut mode = mode;
|
||||
|
||||
// We don't show input command decorations in AI mode, but we keep slash command prefix highlighting.
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
if self.ai_input_model.as_ref(ctx).is_ai_input_enabled()
|
||||
|| (FeatureFlag::AgentView.is_enabled()
|
||||
&& self
|
||||
.slash_command_model
|
||||
.as_ref(ctx)
|
||||
.state()
|
||||
.is_detected_command_or_skill())
|
||||
{
|
||||
self.clear_decorations(ctx);
|
||||
self.apply_slash_command_prefix_highlighting(&buffer_text, ctx);
|
||||
mode.command_decoration = false;
|
||||
|
||||
// Return early because there are no input background jobs to run.
|
||||
if mode.no_jobs_to_run() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match self.completion_session_context_or_empty_context(ctx) {
|
||||
CompletionSessionContext::Session(completion_context) => {
|
||||
let editor = self.editor.as_ref(ctx);
|
||||
let buffer_text = editor.buffer_text(ctx);
|
||||
|
||||
if matches!(&self.last_parsed_tokens, Some(last_parsed_tokens) if buffer_text == last_parsed_tokens.buffer_text)
|
||||
{
|
||||
if mode.ai_input_detection {
|
||||
self.run_input_mode_detection(completion_context, ctx);
|
||||
}
|
||||
|
||||
if mode.command_decoration {
|
||||
self.apply_decorations(ctx);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(handle) = self.decorations_future_handle.take() {
|
||||
handle.abort_handle().abort();
|
||||
}
|
||||
|
||||
let completion_session = completion_context.session.clone();
|
||||
|
||||
self.decorations_future_handle = Some(ctx.spawn_abortable(
|
||||
async move {
|
||||
(
|
||||
parse_current_commands_and_tokens(buffer_text, &completion_context)
|
||||
.await,
|
||||
completion_context,
|
||||
)
|
||||
},
|
||||
move |input, (parsed_tokens, completion_context), ctx| {
|
||||
input.last_parsed_tokens = Some(parsed_tokens);
|
||||
|
||||
if mode.ai_input_detection {
|
||||
input.run_input_mode_detection(completion_context, ctx);
|
||||
}
|
||||
|
||||
if mode.command_decoration {
|
||||
input.apply_decorations(ctx);
|
||||
}
|
||||
},
|
||||
move |_, _| {
|
||||
completion_session.cancel_active_commands();
|
||||
},
|
||||
));
|
||||
}
|
||||
CompletionSessionContext::Empty(detection_ctx) => {
|
||||
if mode.ai_input_detection {
|
||||
// No session context available (e.g., shared session viewer).
|
||||
// Use a dedicated detection context that does not expose top-level commands.
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
let ai_input_model = self.ai_input_model.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
parse_current_commands_and_tokens(buffer_text, &detection_ctx).await
|
||||
},
|
||||
move |_input, parsed_tokens, ctx| {
|
||||
ai_input_model.update(ctx, |model, ctx| {
|
||||
model.detect_and_set_input_type(
|
||||
parsed_tokens,
|
||||
EmptyCompletionContext::new(),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies error underlining and/or syntax highlighting as appropriate,
|
||||
/// using the result of the last parse operation.
|
||||
fn apply_decorations(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
let buffer_text = self.editor.as_ref(ctx).buffer_text(ctx);
|
||||
if buffer_text != parsed_tokens_snapshot.buffer_text {
|
||||
// Our state is out-of-date (parsed_tokens no longer applies to the
|
||||
// updated state of the buffer) and this should be a no-op since
|
||||
// another async callback is likely handling or already handled
|
||||
// this (for a later state) i.e. race condition.
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all decorations before applying the updated ones.
|
||||
self.clear_decorations(ctx);
|
||||
|
||||
let theme = Appearance::as_ref(ctx).theme();
|
||||
let terminal_colors_normal = theme.terminal_colors().normal;
|
||||
|
||||
if self.should_show_syntax_highlighting(ctx) {
|
||||
self.apply_colors_syntax_highlighting_all_tokens(terminal_colors_normal, ctx);
|
||||
}
|
||||
if self.should_show_error_underlining(ctx) {
|
||||
self.apply_colors_error_underlining_all_tokens(terminal_colors_normal, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes decorations (error underlining, syntax highlighting, and background colors) from the input buffer.
|
||||
pub(super) fn clear_decorations(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
vec![CharOffset::from(0)..editor.buffer_size(ctx)],
|
||||
TextStyleOperation::default().clear_decorations(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies error underlining appropriately to all given tokens, given the
|
||||
/// parsed tokens data.
|
||||
///
|
||||
/// This does not unset any existing error underline decorations in the
|
||||
/// editor buffer.
|
||||
fn apply_colors_error_underlining_all_tokens(
|
||||
&mut self,
|
||||
terminal_colors_normal: AnsiColors,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut ranges = vec![];
|
||||
for token_data in &parsed_tokens_snapshot.parsed_tokens {
|
||||
if token_data.token_description.is_none()
|
||||
&& token_data.token_index == 0
|
||||
&& valid_command_for_error_underline(&token_data.token.item)
|
||||
{
|
||||
ranges.push(
|
||||
ByteOffset::from(token_data.token.span.start())
|
||||
..ByteOffset::from(token_data.token.span.end()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !ranges.is_empty() {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
ranges,
|
||||
TextStyleOperation::default().set_error_underline_color(
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into(),
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies syntax highlighting colors appropriately to all given tokens,
|
||||
/// given the parsed tokens data.
|
||||
///
|
||||
/// This does not unset any existing syntax highlighting decorations in the
|
||||
/// editor buffer.
|
||||
fn apply_colors_syntax_highlighting_all_tokens(
|
||||
&mut self,
|
||||
terminal_colors_normal: AnsiColors,
|
||||
ctx: &mut ViewContext<Input>,
|
||||
) {
|
||||
let Some(parsed_tokens_snapshot) = &self.last_parsed_tokens else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut ranges: HashMap<SuggestionTypeName, Vec<Range<ByteOffset>>> = HashMap::new();
|
||||
for token_data in &parsed_tokens_snapshot.parsed_tokens {
|
||||
if let Some(description) = &token_data.token_description {
|
||||
let suggestion_type = description.suggestion_type;
|
||||
let range = ByteOffset::from(token_data.token.span.start())
|
||||
..ByteOffset::from(token_data.token.span.end());
|
||||
ranges
|
||||
.entry(suggestion_type.to_name())
|
||||
.or_default()
|
||||
.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
for (suggestion_type, ranges) in ranges {
|
||||
let color: AnsiColorIdentifier = suggestion_type.into();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.update_buffer_styles(
|
||||
ranges,
|
||||
TextStyleOperation::default()
|
||||
.set_syntax_color(color.to_ansi_color(&terminal_colors_normal).into()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "decorations_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
use warpui::{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 warp_completer::completer::SuggestionTypeName;
|
||||
|
||||
#[test]
|
||||
fn test_decorations_with_multibyte_chars() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let terminal_colors_normal = app
|
||||
.get_singleton_model_handle::<Appearance>()
|
||||
.read(&app, |a, _| a.theme().terminal_colors().normal.to_owned());
|
||||
let error_underline_color = AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into();
|
||||
let command_color = AnsiColorIdentifier::from(SuggestionTypeName::Command)
|
||||
.to_ansi_color(&terminal_colors_normal)
|
||||
.into();
|
||||
|
||||
let session_info = SessionInfo::new_for_test();
|
||||
let session_id = session_info.session_id;
|
||||
|
||||
let terminal =
|
||||
add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await;
|
||||
let input = terminal.read(&app, |view, _| view.input().clone());
|
||||
let editor = input.read(&app, |input, _| input.editor.clone());
|
||||
|
||||
let session_id = terminal.update(&mut app, |terminal_view, ctx| {
|
||||
terminal_view
|
||||
.sessions_model()
|
||||
.update(ctx, |sessions, _ctx| {
|
||||
// Wait until external commands have been loaded.
|
||||
let session = sessions.get(session_id).expect("session should exist");
|
||||
warpui::r#async::block_on(session.load_external_commands());
|
||||
});
|
||||
session_id
|
||||
});
|
||||
|
||||
simulate_directory_for_completion(session_id, &terminal, &mut app, "/usr/bin");
|
||||
|
||||
input
|
||||
.update(&mut app, |input, ctx| {
|
||||
input.clear_buffer_and_reset_undo_stack(ctx);
|
||||
input.user_insert("echo 'multibyte: שלום עולם'\nechoo hello world", ctx);
|
||||
|
||||
// Trigger input decoration computation instead of waiting for the
|
||||
// debounced stream to trigger.
|
||||
input.run_input_background_jobs(
|
||||
InputBackgroundJobOptions::default().with_command_decoration(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Take the future handle from the input to ensure that it doesn't
|
||||
// get cancelled by the debounced decoration update stream.
|
||||
let future_handle = input
|
||||
.decorations_future_handle
|
||||
.take()
|
||||
.expect("should have spanwed decoration task");
|
||||
ctx.await_spawned_future(future_handle.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
let text_style_runs = editor.read(&app, |editor, ctx| {
|
||||
editor
|
||||
.text_style_runs(ctx)
|
||||
.map(|run| (run.text().to_owned(), run.text_style()))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let expected = &[
|
||||
(
|
||||
"echo".to_string(),
|
||||
TextStyle::new().with_syntax_color(command_color),
|
||||
),
|
||||
(" 'multibyte: שלום עולם'\n".to_string(), Default::default()),
|
||||
(
|
||||
"echoo".to_string(),
|
||||
TextStyle::new().with_error_underline_color(error_underline_color),
|
||||
),
|
||||
(" hello world".to_string(), Default::default()),
|
||||
];
|
||||
assert_eq!(
|
||||
text_style_runs, expected,
|
||||
"---- Expected ----\n{expected:#?}\n---- Actual ----\n{text_style_runs:#?}\n"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Data source for the inline history menu, providing both conversations and commands.
|
||||
//!
|
||||
//! Ordering semantics match the legacy up-arrow history menu:
|
||||
//! - Items from different sessions appear before items from the current session
|
||||
//! - Within each group, items are sorted by timestamp (oldest first)
|
||||
//! - Commands are deduplicated, keeping the most recent occurrence
|
||||
//! - The result is that current session items appear at the bottom (closer to input)
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
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::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 ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AcceptHistoryItem {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AcceptHistoryItem {
|
||||
pub fn buffer_replacement_text(&self) -> Option<&String> {
|
||||
match self {
|
||||
AcceptHistoryItem::Command { command, .. } => Some(command),
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Some(query_text),
|
||||
AcceptHistoryItem::Conversation { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptHistoryItem {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::InlineHistoryMenu;
|
||||
|
||||
fn click_behavior(&self) -> InlineMenuClickBehavior {
|
||||
match self {
|
||||
AcceptHistoryItem::Conversation { .. } => InlineMenuClickBehavior::AcceptOnClick,
|
||||
AcceptHistoryItem::Command { .. } | AcceptHistoryItem::AIPrompt { .. } => {
|
||||
InlineMenuClickBehavior::SelectOnClick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data source that provides both live conversations for a terminal view and command history.
|
||||
pub struct InlineHistoryMenuDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
impl InlineHistoryMenuDataSource {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
agent_view_controller,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_agent_view_results(
|
||||
&self,
|
||||
query: &Query,
|
||||
prefix_match_len: usize,
|
||||
session_id: Option<SessionId>,
|
||||
app: &AppContext,
|
||||
) -> Vec<QueryResult<AcceptHistoryItem>> {
|
||||
let trimmed_query = query.text.trim();
|
||||
let include_commands =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Commands);
|
||||
let include_prompts =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::PromptHistory);
|
||||
|
||||
let history = History::handle(app).as_ref(app);
|
||||
let config = UpArrowHistoryConfig {
|
||||
include_commands,
|
||||
include_prompts,
|
||||
};
|
||||
let suggestions = history.up_arrow_suggestions_for_terminal_view(
|
||||
self.terminal_view_id,
|
||||
session_id,
|
||||
config,
|
||||
app,
|
||||
);
|
||||
|
||||
let mut results: Vec<QueryResult<AcceptHistoryItem>> = Vec::new();
|
||||
for suggestion in suggestions {
|
||||
if !trimmed_query.is_empty() && !suggestion.text().starts_with(trimmed_query) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (search_item, score) = match suggestion {
|
||||
HistoryInputSuggestion::Command { entry } => {
|
||||
let command = entry.command.trim();
|
||||
if command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let timestamp = entry.start_ts.unwrap_or_else(Local::now);
|
||||
(
|
||||
InlineHistoryItem::command(
|
||||
command.to_string(),
|
||||
entry.linked_workflow_data(),
|
||||
timestamp,
|
||||
)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
OrderedFloat(results.len() as f64),
|
||||
)
|
||||
}
|
||||
HistoryInputSuggestion::AIQuery { entry } => {
|
||||
let query_text = entry.query_text.trim();
|
||||
if query_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
InlineHistoryItem::ai_prompt(query_text.to_string(), entry.start_time)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
OrderedFloat(results.len() as f64),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
results.push(QueryResult::from(search_item.with_score(score)));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn build_conversation_entries(&self, trimmed_query: &str, app: &AppContext) -> Vec<MenuEntry> {
|
||||
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)
|
||||
{
|
||||
if conversation.is_entirely_passive() || conversation.exchange_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(timestamp) = conversation.last_modified_at() else {
|
||||
continue;
|
||||
};
|
||||
let title = conversation
|
||||
.title()
|
||||
.unwrap_or_else(|| "Untitled conversation".to_string());
|
||||
let match_result = if trimmed_query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let result = fuzzy_match::match_indices_case_insensitive(&title, trimmed_query);
|
||||
if result.is_none() || result.as_ref().is_some_and(|r| r.score < 50) {
|
||||
continue;
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
conversation_entries.push(MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: timestamp,
|
||||
item: MenuItem::Conversation {
|
||||
conversation_id: conversation.id(),
|
||||
title,
|
||||
status: conversation.status().clone(),
|
||||
match_result,
|
||||
display_timestamp: timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversation_entries.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then(a.sort_timestamp.cmp(&b.sort_timestamp))
|
||||
});
|
||||
conversation_entries
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MenuEntry {
|
||||
order: HistoryOrder,
|
||||
sort_timestamp: DateTime<Local>,
|
||||
item: MenuItem,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MenuItem {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
match_result: Option<FuzzyMatchResult>,
|
||||
display_timestamp: DateTime<Local>,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
display_timestamp: DateTime<Local>,
|
||||
prefix_match_len: usize,
|
||||
},
|
||||
}
|
||||
|
||||
fn interleave_conversations(base: Vec<MenuEntry>, conversations: Vec<MenuEntry>) -> Vec<MenuEntry> {
|
||||
let current_start_idx = base
|
||||
.iter()
|
||||
.position(|e| e.order == HistoryOrder::CurrentSession)
|
||||
.unwrap_or(base.len());
|
||||
|
||||
let mut merged: Vec<MenuEntry> = Vec::with_capacity(base.len() + conversations.len());
|
||||
merged.extend(base.iter().take(current_start_idx).cloned());
|
||||
|
||||
let base_current = base.into_iter().skip(current_start_idx).collect::<Vec<_>>();
|
||||
let mut conversations = conversations;
|
||||
conversations.sort_by(|a, b| a.sort_timestamp.cmp(&b.sort_timestamp));
|
||||
|
||||
let mut i = 0;
|
||||
for conv in conversations {
|
||||
while i < base_current.len() && base_current[i].sort_timestamp < conv.sort_timestamp {
|
||||
merged.push(base_current[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
merged.push(conv);
|
||||
}
|
||||
merged.extend(base_current.into_iter().skip(i));
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
impl SyncDataSource for InlineHistoryMenuDataSource {
|
||||
type Action = AcceptHistoryItem;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let trimmed_query = query.text.trim();
|
||||
let prefix_match_len = trimmed_query.len();
|
||||
|
||||
let session_id = self.active_session.as_ref(app).session(app).map(|s| s.id());
|
||||
let is_agent_view = self.agent_view_controller.as_ref(app).is_active();
|
||||
|
||||
if is_agent_view {
|
||||
return Ok(self.build_agent_view_results(query, prefix_match_len, session_id, app));
|
||||
}
|
||||
|
||||
let include_commands =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Commands);
|
||||
let include_conversations =
|
||||
query.filters.is_empty() || query.filters.contains(&QueryFilter::Conversations);
|
||||
|
||||
let history = History::handle(app).as_ref(app);
|
||||
let all_live_session_ids = history.all_live_session_ids();
|
||||
|
||||
let command_entries = if include_commands {
|
||||
history
|
||||
.up_arrow_suggestions_for_terminal_view(
|
||||
self.terminal_view_id,
|
||||
session_id,
|
||||
UpArrowHistoryConfig {
|
||||
include_commands: true,
|
||||
include_prompts: false,
|
||||
},
|
||||
app,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|suggestion| {
|
||||
let HistoryInputSuggestion::Command { entry } = &suggestion else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let command = entry.command.trim();
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !trimmed_query.is_empty() && !command.starts_with(trimmed_query) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let order = suggestion.history_order(session_id, &all_live_session_ids);
|
||||
let sort_timestamp = entry.start_ts.unwrap_or_default();
|
||||
let display_timestamp = entry.start_ts.unwrap_or_else(Local::now);
|
||||
|
||||
Some(MenuEntry {
|
||||
order,
|
||||
sort_timestamp,
|
||||
item: MenuItem::Command {
|
||||
command: command.to_string(),
|
||||
linked_workflow_data: entry.linked_workflow_data(),
|
||||
display_timestamp,
|
||||
prefix_match_len,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let conversation_entries = if include_conversations {
|
||||
self.build_conversation_entries(trimmed_query, app)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let merged_entries = interleave_conversations(command_entries, conversation_entries);
|
||||
|
||||
let mut results: Vec<QueryResult<AcceptHistoryItem>> = Vec::new();
|
||||
for entry in merged_entries {
|
||||
let score = OrderedFloat(results.len() as f64);
|
||||
let search_item = match entry.item {
|
||||
MenuItem::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
match_result,
|
||||
display_timestamp,
|
||||
} => InlineHistoryItem::conversation(
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
display_timestamp,
|
||||
)
|
||||
.with_name_match_result(match_result),
|
||||
MenuItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
display_timestamp,
|
||||
prefix_match_len,
|
||||
} => InlineHistoryItem::command(command, linked_workflow_data, display_timestamp)
|
||||
.with_prefix_match_len(prefix_match_len),
|
||||
};
|
||||
|
||||
results.push(QueryResult::from(search_item.with_score(score)));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineHistoryMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "data_source_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,97 @@
|
||||
use chrono::{Local, TimeZone as _};
|
||||
|
||||
use crate::input_suggestions::HistoryOrder;
|
||||
|
||||
use super::{interleave_conversations, MenuEntry, MenuItem};
|
||||
|
||||
#[test]
|
||||
fn interleave_conversations_only_inserts_into_current_session_segment() {
|
||||
let t10 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 10).unwrap();
|
||||
let t20 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 20).unwrap();
|
||||
let t50 = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 50).unwrap();
|
||||
let t100 = Local.with_ymd_and_hms(2024, 1, 1, 0, 1, 40).unwrap();
|
||||
let t150 = Local.with_ymd_and_hms(2024, 1, 1, 0, 2, 30).unwrap();
|
||||
let t200 = Local.with_ymd_and_hms(2024, 1, 1, 0, 3, 20).unwrap();
|
||||
|
||||
let base = vec![
|
||||
MenuEntry {
|
||||
order: HistoryOrder::DifferentSession,
|
||||
sort_timestamp: t10,
|
||||
item: MenuItem::Command {
|
||||
command: "d10".to_string(),
|
||||
display_timestamp: t10,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::DifferentSession,
|
||||
sort_timestamp: t20,
|
||||
item: MenuItem::Command {
|
||||
command: "d20".to_string(),
|
||||
display_timestamp: t20,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t100,
|
||||
item: MenuItem::Command {
|
||||
command: "c100".to_string(),
|
||||
display_timestamp: t100,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t200,
|
||||
item: MenuItem::Command {
|
||||
command: "c200".to_string(),
|
||||
display_timestamp: t200,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let conversations = vec![
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t150,
|
||||
item: MenuItem::Command {
|
||||
command: "conv150".to_string(),
|
||||
display_timestamp: t150,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
MenuEntry {
|
||||
order: HistoryOrder::CurrentSession,
|
||||
sort_timestamp: t50,
|
||||
item: MenuItem::Command {
|
||||
command: "conv50".to_string(),
|
||||
display_timestamp: t50,
|
||||
linked_workflow_data: None,
|
||||
prefix_match_len: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let merged = interleave_conversations(base, conversations);
|
||||
|
||||
let commands = merged
|
||||
.iter()
|
||||
.map(|e| match &e.item {
|
||||
MenuItem::Command { command, .. } => command.as_str(),
|
||||
MenuItem::Conversation { title, .. } => title.as_str(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// DifferentSession entries stay at the top; conversations are inserted into the CurrentSession segment.
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["d10", "d20", "conv50", "c100", "conv150", "c200"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Inline history menu for up-arrow history when `FeatureFlag::AgentView` is enabled.
|
||||
//!
|
||||
//! Shows both live conversations for the terminal view and command history in the terminal
|
||||
//! view, and prompts and command history in the agent view.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use data_source::AcceptHistoryItem;
|
||||
pub use view::{HistoryTab, InlineHistoryMenuEvent, InlineHistoryMenuView};
|
||||
@@ -0,0 +1,285 @@
|
||||
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 ordered_float::OrderedFloat;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::scene::{CornerRadius, Radius};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InlineHistoryItem {
|
||||
item_type: HistoryItemType,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
prefix_match_len: usize,
|
||||
score: OrderedFloat<f64>,
|
||||
timestamp: DateTime<Local>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum HistoryItemType {
|
||||
Conversation {
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
},
|
||||
Command {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl InlineHistoryItem {
|
||||
pub fn conversation(
|
||||
conversation_id: AIConversationId,
|
||||
title: String,
|
||||
status: ConversationStatus,
|
||||
timestamp: DateTime<Local>,
|
||||
) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
status,
|
||||
},
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
timestamp: DateTime<Local>,
|
||||
) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
},
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ai_prompt(query_text: String, timestamp: DateTime<Local>) -> Self {
|
||||
Self {
|
||||
item_type: HistoryItemType::AIPrompt { query_text },
|
||||
name_match_result: None,
|
||||
prefix_match_len: 0,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_prefix_match_len(mut self, len: usize) -> Self {
|
||||
self.prefix_match_len = len;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for InlineHistoryItem {
|
||||
type Action = AcceptHistoryItem;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = inline_styles::font_size(appearance);
|
||||
let icon = match &self.item_type {
|
||||
HistoryItemType::Conversation { status, .. } => {
|
||||
render_status_element(status, icon_size, appearance)
|
||||
}
|
||||
HistoryItemType::Command { .. } => {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::Terminal.to_warpui_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(
|
||||
inline_styles::ITEM_CORNER_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
HistoryItemType::AIPrompt { .. } => {
|
||||
let icon_color = inline_styles::icon_color(appearance);
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::Prompt.to_warpui_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(
|
||||
inline_styles::ITEM_CORNER_RADIUS,
|
||||
)))
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color =
|
||||
inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
let (display_text, match_indices, font_family) = match &self.item_type {
|
||||
HistoryItemType::Conversation { title, .. } => {
|
||||
let indices = self
|
||||
.name_match_result
|
||||
.as_ref()
|
||||
.map(|m| m.matched_indices.clone())
|
||||
.unwrap_or_default();
|
||||
(title.clone(), indices, appearance.ui_font_family())
|
||||
}
|
||||
HistoryItemType::Command { command, .. } => {
|
||||
let indices = if self.prefix_match_len > 0 {
|
||||
(0..self.prefix_match_len).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
(command.clone(), indices, appearance.monospace_font_family())
|
||||
}
|
||||
HistoryItemType::AIPrompt { query_text } => {
|
||||
let indices = if self.prefix_match_len > 0 {
|
||||
(0..self.prefix_match_len).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
(query_text.clone(), indices, appearance.ui_font_family())
|
||||
}
|
||||
};
|
||||
|
||||
let mut text = Text::new_inline(display_text, font_family, font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if !match_indices.is_empty() {
|
||||
text = text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
match_indices,
|
||||
);
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., text.finish()).finish());
|
||||
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(self.timestamp.to_utc()),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
match &self.item_type {
|
||||
HistoryItemType::Conversation {
|
||||
conversation_id,
|
||||
title,
|
||||
..
|
||||
} => AcceptHistoryItem::Conversation {
|
||||
conversation_id: *conversation_id,
|
||||
title: title.clone(),
|
||||
},
|
||||
HistoryItemType::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => AcceptHistoryItem::Command {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
},
|
||||
HistoryItemType::AIPrompt { query_text } => AcceptHistoryItem::AIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
match &self.item_type {
|
||||
HistoryItemType::Conversation { title, .. } => format!("Conversation: {title}"),
|
||||
HistoryItemType::Command { command, .. } => format!("Command: {command}"),
|
||||
HistoryItemType::AIPrompt { query_text } => format!("AI prompt: {query_text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Inline history menu view for up-arrow history with conversations, commands and prompts.
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{AppContext, Element, Entity, EntityId, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::features::FeatureFlag;
|
||||
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::inline_history::data_source::{
|
||||
AcceptHistoryItem, InlineHistoryMenuDataSource,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuHeaderConfig, InlineMenuModel, InlineMenuPositioner,
|
||||
InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineHistoryMenuEvent {
|
||||
NavigateToConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
AcceptCommand {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
AcceptAIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
SelectCommand {
|
||||
command: String,
|
||||
linked_workflow_data: Option<LinkedWorkflowData>,
|
||||
},
|
||||
SelectAIPrompt {
|
||||
query_text: String,
|
||||
},
|
||||
/// Emitted when a conversation row becomes selected so any previously
|
||||
/// previewed command or prompt text is cleared from the input buffer.
|
||||
SelectConversation,
|
||||
NoResults,
|
||||
/// Emitted when the inline menu should be closed and additionally restore the
|
||||
/// original input buffer contents.
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Identifies a history item well enough to reselect the same logical item
|
||||
/// after rerunning the current query.
|
||||
enum HistoryItemIdentity {
|
||||
Conversation(AIConversationId),
|
||||
Command(String),
|
||||
AIPrompt(String),
|
||||
}
|
||||
|
||||
impl HistoryItemIdentity {
|
||||
fn from_item(item: &AcceptHistoryItem) -> Self {
|
||||
match item {
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
} => Self::Conversation(*conversation_id),
|
||||
AcceptHistoryItem::Command { command, .. } => Self::Command(command.clone()),
|
||||
AcceptHistoryItem::AIPrompt { query_text } => Self::AIPrompt(query_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, item: &AcceptHistoryItem) -> bool {
|
||||
match (self, item) {
|
||||
(
|
||||
Self::Conversation(expected_id),
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
},
|
||||
) => *expected_id == *conversation_id,
|
||||
(Self::Command(expected_command), AcceptHistoryItem::Command { command, .. }) => {
|
||||
expected_command == command
|
||||
}
|
||||
(Self::AIPrompt(expected_query), AcceptHistoryItem::AIPrompt { query_text }) => {
|
||||
expected_query == query_text
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigureButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for ConfigureButtonTheme {
|
||||
fn background(&self, _hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
let theme = appearance.theme();
|
||||
if hovered {
|
||||
internal_colors::text_main(theme, theme.background())
|
||||
} else {
|
||||
internal_colors::text_sub(theme, theme.background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HistoryTab {
|
||||
All,
|
||||
Commands,
|
||||
Prompts,
|
||||
}
|
||||
|
||||
fn build_tab_configs(is_agent_view: bool) -> Vec<InlineMenuTabConfig<HistoryTab>> {
|
||||
if !is_agent_view {
|
||||
return vec![InlineMenuTabConfig {
|
||||
id: HistoryTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
}];
|
||||
}
|
||||
|
||||
vec![
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::All,
|
||||
label: "All".to_string(),
|
||||
filters: HashSet::new(),
|
||||
},
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::Commands,
|
||||
label: "Commands".to_string(),
|
||||
filters: HashSet::from([QueryFilter::Commands]),
|
||||
},
|
||||
InlineMenuTabConfig {
|
||||
id: HistoryTab::Prompts,
|
||||
label: "Prompts".to_string(),
|
||||
filters: HashSet::from([QueryFilter::PromptHistory]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub struct InlineHistoryMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptHistoryItem, HistoryTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptHistoryItem>>,
|
||||
model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
pending_tab_switch_selection: Option<HistoryItemIdentity>,
|
||||
caller_supplied_tabs: bool,
|
||||
}
|
||||
|
||||
impl InlineHistoryMenuView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let is_agent_view = agent_view_controller.as_ref(ctx).is_active();
|
||||
let tab_configs = build_tab_configs(is_agent_view);
|
||||
Self::new_inner(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
/* caller_supplied_tabs */ false,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_tab_configs(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<HistoryTab>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self::new_inner(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
input_suggestions_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
buffer_model,
|
||||
tab_configs,
|
||||
/* caller_supplied_tabs */ true,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new_inner(
|
||||
terminal_view_id: EntityId,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
input_suggestions_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
buffer_model: ModelHandle<InputBufferModel>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<HistoryTab>>,
|
||||
caller_supplied_tabs: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| {
|
||||
InlineHistoryMenuDataSource::new(
|
||||
terminal_view_id,
|
||||
active_session,
|
||||
agent_view_controller.clone(),
|
||||
)
|
||||
});
|
||||
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptHistoryItem>::new();
|
||||
mixer.add_sync_source(
|
||||
data_source,
|
||||
[
|
||||
QueryFilter::Commands,
|
||||
QueryFilter::Conversations,
|
||||
QueryFilter::PromptHistory,
|
||||
],
|
||||
);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
let configure_button = ctx.add_view(|_| {
|
||||
ActionButton::new("Configure", ConfigureButtonTheme)
|
||||
.with_icon(Icon::Settings)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "commands history".into(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
});
|
||||
let header_config = InlineMenuHeaderConfig {
|
||||
label: "History".to_string(),
|
||||
trailing_element: Some(Box::new(move |_app: &AppContext| {
|
||||
ChildView::new(&configure_button).finish()
|
||||
})),
|
||||
};
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
input_suggestions_model,
|
||||
agent_view_controller.clone(),
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.with_header_config(header_config)
|
||||
})
|
||||
} else {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
input_suggestions_model,
|
||||
agent_view_controller.clone(),
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
};
|
||||
let model = menu_view.as_ref(ctx).model().clone();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
let suggestions_mode_model = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&agent_view_controller,
|
||||
move |me, controller, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. } => {
|
||||
// Only auto-rebuild tabs from `is_agent_view` when the
|
||||
// caller did not supply tabs explicitly. Callers that
|
||||
// pinned tabs (e.g. the cloud-mode V2 wrapper) want their
|
||||
// tab set preserved across agent-view enter/exit.
|
||||
if !me.caller_supplied_tabs {
|
||||
let is_agent_view = controller.as_ref(ctx).is_active();
|
||||
let new_configs = build_tab_configs(is_agent_view);
|
||||
me.model.update(ctx, |model, _| {
|
||||
model.set_tab_configs(new_configs);
|
||||
});
|
||||
if suggestions_mode_model.as_ref(ctx).is_inline_history_menu() {
|
||||
me.pending_tab_switch_selection = me
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_item()
|
||||
.map(HistoryItemIdentity::from_item);
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
}
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
AgentViewControllerEvent::ExitConfirmed { .. } => {}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => match item {
|
||||
AcceptHistoryItem::Conversation {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::NavigateToConversation {
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::AcceptCommand {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::AIPrompt { query_text } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::AcceptAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
},
|
||||
InlineMenuEvent::SelectedItem { item } => match item {
|
||||
AcceptHistoryItem::Command {
|
||||
command,
|
||||
linked_workflow_data,
|
||||
} => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectCommand {
|
||||
command: command.clone(),
|
||||
linked_workflow_data: linked_workflow_data.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::AIPrompt { query_text } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectAIPrompt {
|
||||
query_text: query_text.clone(),
|
||||
});
|
||||
}
|
||||
AcceptHistoryItem::Conversation { .. } => {
|
||||
ctx.emit(InlineHistoryMenuEvent::SelectConversation);
|
||||
}
|
||||
},
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineHistoryMenuEvent::Close);
|
||||
}
|
||||
InlineMenuEvent::NoResults => {
|
||||
ctx.emit(InlineHistoryMenuEvent::NoResults);
|
||||
}
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.pending_tab_switch_selection = me
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.selected_item()
|
||||
.map(HistoryItemIdentity::from_item);
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
let suggestions_mode_model = input_suggestions_model.clone();
|
||||
ctx.subscribe_to_model(&mixer, move |me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
if !suggestions_mode_model.as_ref(ctx).is_inline_history_menu() {
|
||||
return;
|
||||
}
|
||||
// Only tab switches stash a pending selection to restore after the
|
||||
// query reruns. For all other result updates, keep the inline menu's
|
||||
// default selection behavior.
|
||||
let Some(selection) = me.pending_tab_switch_selection.take() else {
|
||||
return;
|
||||
};
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_last_where(|item| selection.matches(item), ctx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
model,
|
||||
buffer_model,
|
||||
pending_tab_switch_selection: None,
|
||||
caller_supplied_tabs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the model handle for external use (e.g., by message bars).
|
||||
pub fn model(&self) -> &ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>> {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn render_results_only(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.menu_view.as_ref(app).render_results_only(
|
||||
/* should_render_results_in_reverse */ false, /* horizontal_padding */ 0.,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn result_count(&self, app: &AppContext) -> usize {
|
||||
self.menu_view.as_ref(app).result_count()
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
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 select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
|
||||
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();
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self.model.as_ref(ctx).active_tab_filters();
|
||||
let query_text = self.current_query_text(ctx);
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: query_text,
|
||||
filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn current_query_text(&self, ctx: &ViewContext<Self>) -> String {
|
||||
// Read the query text from the mixer rather than the editor buffer. While the
|
||||
// inline history menu is open, moving the highlighted row can preview that
|
||||
// item in the editor buffer. When we rerun the search after a tab change, we
|
||||
// want to preserve the user's typed query, not the temporary preview text.
|
||||
self.mixer
|
||||
.as_ref(ctx)
|
||||
.current_query()
|
||||
.map(|q| q.text.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineHistoryMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineHistoryMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineHistoryMenuView {
|
||||
type Event = InlineHistoryMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::elements::Element;
|
||||
use warpui::prelude::Container;
|
||||
use warpui::scene::Border;
|
||||
use warpui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::terminal::input::inline_menu::model::InlineMenuModel;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuAction, InlineMenuMessageProvider, InlineMenuPositioner,
|
||||
};
|
||||
use crate::terminal::input::message_bar::common::render_standard_message_bar;
|
||||
use crate::terminal::input::message_bar::{EmptyMessageProducer, MessageProvider};
|
||||
|
||||
pub struct InlineMenuMessageBarArgs<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
|
||||
pub inline_menu_model: ModelHandle<InlineMenuModel<A, T>>,
|
||||
pub agent_view_controller: ModelHandle<AgentViewController>,
|
||||
pub positioner: ModelHandle<InlineMenuPositioner>,
|
||||
}
|
||||
|
||||
/// Renders contextual hint text at the bottom of the agent view status bar.
|
||||
pub struct InlineMenuMessageBar<A: InlineMenuAction, T: 'static + Send + Sync = ()> {
|
||||
inline_menu_model: ModelHandle<InlineMenuModel<A, T>>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: ModelHandle<InlineMenuPositioner>,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> Entity for InlineMenuMessageBar<A, T> {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> InlineMenuMessageBar<A, T> {
|
||||
pub fn new(args: InlineMenuMessageBarArgs<A, T>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let InlineMenuMessageBarArgs {
|
||||
inline_menu_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
} = args;
|
||||
|
||||
ctx.subscribe_to_model(&inline_menu_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView { .. }
|
||||
| AgentViewControllerEvent::ExitedAgentView { .. } => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
|
||||
Self {
|
||||
inline_menu_model,
|
||||
agent_view_controller,
|
||||
positioner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const INLINE_MENU_BORDER_WIDTH: f32 = 1.;
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> View for InlineMenuMessageBar<A, T> {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineMenuMessageBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let inline_menu_model = self.inline_menu_model.as_ref(app);
|
||||
|
||||
let message = InlineMenuMessageProvider::<A>::default()
|
||||
.produce_message(InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
})
|
||||
.or_else(|| {
|
||||
EmptyMessageProducer.produce_message(InlineMenuMessageArgs {
|
||||
inline_menu_model,
|
||||
app,
|
||||
})
|
||||
})
|
||||
.expect("Empty message producer always returns Some().");
|
||||
|
||||
let message_bar = render_standard_message_bar(message, None, app);
|
||||
if !self.agent_view_controller.as_ref(app).is_active() {
|
||||
let is_rendering_below_input = self
|
||||
.positioner
|
||||
.as_ref(app)
|
||||
.should_render_inline_menu_below_input();
|
||||
Container::new(message_bar)
|
||||
.with_border(
|
||||
Border::new(INLINE_MENU_BORDER_WIDTH)
|
||||
.with_sides(
|
||||
is_rendering_below_input,
|
||||
false,
|
||||
!is_rendering_below_input,
|
||||
false,
|
||||
)
|
||||
.with_border_fill(Appearance::as_ref(app).theme().outline()),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
message_bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for inline menu message producers.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct InlineMenuMessageArgs<'a, A: InlineMenuAction, T = ()> {
|
||||
pub inline_menu_model: &'a InlineMenuModel<A, T>,
|
||||
pub app: &'a AppContext,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use warpui::{keymap::Keystroke, AppContext};
|
||||
|
||||
use crate::editor::{SELECT_DOWN_ACTION_NAME, SELECT_UP_ACTION_NAME};
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem, MessageProvider};
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
|
||||
/// Generic message provider for inline menus.
|
||||
///
|
||||
/// The message line is what contains the up/down navigation hints and optionally,
|
||||
/// contextual CTAs based on the currently selected item.
|
||||
pub struct InlineMenuMessageProvider<A>(PhantomData<A>);
|
||||
|
||||
impl<A> Default for InlineMenuMessageProvider<A> {
|
||||
fn default() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, T> MessageProvider<InlineMenuMessageArgs<'_, A, T>> for InlineMenuMessageProvider<A>
|
||||
where
|
||||
A: InlineMenuAction,
|
||||
{
|
||||
fn produce_message(&self, args: InlineMenuMessageArgs<'_, A, T>) -> Option<Message> {
|
||||
A::produce_inline_menu_message(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default set of navigation/dismiss message items that should be rendered in all
|
||||
/// inline menus.
|
||||
pub fn default_navigation_message_items<A: InlineMenuAction, T>(
|
||||
args: &InlineMenuMessageArgs<'_, A, T>,
|
||||
) -> Vec<MessageItem> {
|
||||
let navigation_keystrokes = navigation_keystrokes(args.app);
|
||||
let mut items = vec![
|
||||
MessageItem::keystroke(navigation_keystrokes.0),
|
||||
MessageItem::keystroke(navigation_keystrokes.1),
|
||||
MessageItem::text(" to navigate"),
|
||||
];
|
||||
|
||||
if args.inline_menu_model.tab_configs().len() > 1 {
|
||||
items.push(MessageItem::keystroke(Keystroke {
|
||||
key: "tab".to_owned(),
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}));
|
||||
items.push(MessageItem::text(" to cycle tabs"));
|
||||
}
|
||||
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to dismiss"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::<A>::Dismiss);
|
||||
},
|
||||
args.inline_menu_model.mouse_states().dismiss.clone(),
|
||||
));
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn navigation_keystrokes(app: &AppContext) -> (Keystroke, Keystroke) {
|
||||
static DEFAULT_SELECT_UP_BINDING: LazyLock<Option<Keystroke>> = LazyLock::new(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(Keystroke {
|
||||
key: "P".to_owned(),
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
static DEFAULT_SELECT_DOWN_BINDING: LazyLock<Option<Keystroke>> = LazyLock::new(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(Keystroke {
|
||||
key: "N".to_owned(),
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((up_keystroke, down_keystroke)) =
|
||||
keybinding_name_to_keystroke(SELECT_UP_ACTION_NAME, app)
|
||||
.zip(keybinding_name_to_keystroke(SELECT_DOWN_ACTION_NAME, app))
|
||||
.filter(|(up_binding, down_binding)| {
|
||||
Some(up_binding) != DEFAULT_SELECT_UP_BINDING.as_ref()
|
||||
&& Some(down_binding) != DEFAULT_SELECT_DOWN_BINDING.as_ref()
|
||||
})
|
||||
{
|
||||
(up_keystroke, down_keystroke)
|
||||
} else {
|
||||
(
|
||||
Keystroke {
|
||||
key: "up".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
Keystroke {
|
||||
key: "down".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Generic inline menu view for rendering search results with selection and navigation.
|
||||
mod message_bar;
|
||||
mod message_provider;
|
||||
mod model;
|
||||
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;
|
||||
pub use view::{
|
||||
DetailsRenderConfig, InlineMenuAction, InlineMenuClickBehavior, InlineMenuEvent,
|
||||
InlineMenuHeaderConfig, InlineMenuRowAction, InlineMenuView,
|
||||
};
|
||||
|
||||
/// Identifies a specific inline menu type.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Identifies a specific inline menu.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum InlineMenuType {
|
||||
SlashCommands,
|
||||
ModelSelector,
|
||||
ConversationMenu,
|
||||
ProfileSelector,
|
||||
PromptsMenu,
|
||||
SkillMenu,
|
||||
UserQueryMenu,
|
||||
RewindMenu,
|
||||
InlineHistoryMenu,
|
||||
IndexedReposMenu,
|
||||
PlanMenu,
|
||||
}
|
||||
|
||||
impl InlineMenuType {
|
||||
fn display_label(&self) -> &'static str {
|
||||
match self {
|
||||
InlineMenuType::SlashCommands => "/Commands",
|
||||
InlineMenuType::ModelSelector => "/Model",
|
||||
InlineMenuType::ConversationMenu => "/Conversations",
|
||||
InlineMenuType::ProfileSelector => "/Profiles",
|
||||
InlineMenuType::PromptsMenu => "/Prompts",
|
||||
InlineMenuType::SkillMenu => "/Skills",
|
||||
InlineMenuType::UserQueryMenu => "/Fork",
|
||||
InlineMenuType::RewindMenu => "/Rewind",
|
||||
InlineMenuType::InlineHistoryMenu => "History",
|
||||
InlineMenuType::IndexedReposMenu => "/Repos",
|
||||
InlineMenuType::PlanMenu => "/Plans",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_suggestions_mode(mode: &InputSuggestionsMode) -> Option<Self> {
|
||||
match mode {
|
||||
InputSuggestionsMode::SlashCommands => Some(InlineMenuType::SlashCommands),
|
||||
InputSuggestionsMode::ModelSelector => Some(InlineMenuType::ModelSelector),
|
||||
InputSuggestionsMode::ConversationMenu => Some(InlineMenuType::ConversationMenu),
|
||||
InputSuggestionsMode::ProfileSelector => Some(InlineMenuType::ProfileSelector),
|
||||
InputSuggestionsMode::PromptsMenu => Some(InlineMenuType::PromptsMenu),
|
||||
InputSuggestionsMode::SkillMenu => Some(InlineMenuType::SkillMenu),
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: UserQueryMenuAction::ForkFrom,
|
||||
..
|
||||
} => Some(InlineMenuType::UserQueryMenu),
|
||||
InputSuggestionsMode::UserQueryMenu {
|
||||
action: UserQueryMenuAction::Rewind,
|
||||
..
|
||||
} => Some(InlineMenuType::RewindMenu),
|
||||
InputSuggestionsMode::InlineHistoryMenu { .. } => {
|
||||
Some(InlineMenuType::InlineHistoryMenu)
|
||||
}
|
||||
InputSuggestionsMode::IndexedReposMenu => Some(InlineMenuType::IndexedReposMenu),
|
||||
InputSuggestionsMode::PlanMenu { .. } => Some(InlineMenuType::PlanMenu),
|
||||
InputSuggestionsMode::Closed
|
||||
| InputSuggestionsMode::HistoryUp { .. }
|
||||
| InputSuggestionsMode::CompletionSuggestions { .. }
|
||||
| InputSuggestionsMode::StaticWorkflowEnumSuggestions { .. }
|
||||
| InputSuggestionsMode::DynamicWorkflowEnumSuggestions { .. }
|
||||
| InputSuggestionsMode::AIContextMenu { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Generic model for tracking the selected item in an inline menu.
|
||||
use std::collections::HashSet;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::search::data_source::QueryFilter;
|
||||
use crate::terminal::input::inline_menu::view::InlineMenuAction;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InlineMenuMouseStates {
|
||||
pub accept: MouseStateHandle,
|
||||
pub accept_secondary: MouseStateHandle,
|
||||
pub dismiss: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Configuration for a single tab in the inline menu.
|
||||
///
|
||||
/// Each tab has a label (displayed in the header) and a set of query filters
|
||||
/// that are applied when the tab is active.
|
||||
#[derive(Clone)]
|
||||
pub struct InlineMenuTabConfig<T = ()> {
|
||||
pub id: T,
|
||||
pub label: String,
|
||||
pub filters: HashSet<QueryFilter>,
|
||||
}
|
||||
|
||||
/// This model is generic over the action type `A` (the same type used by the `SearchMixer`).
|
||||
/// It serves as a denormalized copy of the selected item that can be read/subscribed to
|
||||
/// without requiring a strict dependency on the inline menu view itself. It also tracks
|
||||
/// the inline menu's selected tab (if any).
|
||||
pub struct InlineMenuModel<A: InlineMenuAction, T = ()> {
|
||||
selected_item: Option<A>,
|
||||
tab_configs: Vec<InlineMenuTabConfig<T>>,
|
||||
active_tab_index: usize,
|
||||
mouse_states: InlineMenuMouseStates,
|
||||
tab_mouse_states: Vec<MouseStateHandle>,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> Default for InlineMenuModel<A> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction> InlineMenuModel<A> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selected_item: None,
|
||||
tab_configs: Vec::new(),
|
||||
active_tab_index: 0,
|
||||
mouse_states: InlineMenuMouseStates::default(),
|
||||
tab_mouse_states: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: Clone + PartialEq> InlineMenuModel<A, T> {
|
||||
pub fn new_with_tabs(tab_configs: Vec<InlineMenuTabConfig<T>>, initial_tab: Option<T>) -> Self {
|
||||
let tab_mouse_states = tab_configs
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
let active_tab_index = initial_tab
|
||||
.and_then(|id| tab_configs.iter().position(|config| config.id == id))
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
selected_item: None,
|
||||
tab_configs,
|
||||
active_tab_index,
|
||||
mouse_states: InlineMenuMouseStates::default(),
|
||||
tab_mouse_states,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_tab_id(&self) -> Option<T> {
|
||||
self.tab_configs
|
||||
.get(self.active_tab_index)
|
||||
.map(|config| config.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T> InlineMenuModel<A, T> {
|
||||
/// Returns a reference to the currently selected item, if any.
|
||||
pub fn selected_item(&self) -> Option<&A> {
|
||||
self.selected_item.as_ref()
|
||||
}
|
||||
|
||||
pub fn tab_configs(&self) -> &[InlineMenuTabConfig<T>] {
|
||||
&self.tab_configs
|
||||
}
|
||||
|
||||
pub fn active_tab_index(&self) -> usize {
|
||||
self.active_tab_index
|
||||
}
|
||||
|
||||
pub fn active_tab_filters(&self) -> HashSet<QueryFilter> {
|
||||
self.tab_configs
|
||||
.get(self.active_tab_index)
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mouse_states(&self) -> &InlineMenuMouseStates {
|
||||
&self.mouse_states
|
||||
}
|
||||
|
||||
pub fn tab_mouse_states(&self) -> &[MouseStateHandle] {
|
||||
&self.tab_mouse_states
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: Send + Sync + 'static> InlineMenuModel<A, T> {
|
||||
pub fn set_tab_configs(&mut self, tab_configs: Vec<InlineMenuTabConfig<T>>) {
|
||||
if self.tab_mouse_states.len() < tab_configs.len() {
|
||||
self.tab_mouse_states
|
||||
.resize_with(tab_configs.len(), MouseStateHandle::default);
|
||||
}
|
||||
self.tab_configs = tab_configs;
|
||||
if self.active_tab_index >= self.tab_configs.len() {
|
||||
self.active_tab_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_active_tab_index(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
|
||||
if index != self.active_tab_index && index < self.tab_configs.len() {
|
||||
self.active_tab_index = index;
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedActiveTab);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_selected_item(&mut self, item: A, ctx: &mut ModelContext<Self>) {
|
||||
self.selected_item = Some(item);
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedSelectedItem);
|
||||
}
|
||||
|
||||
pub(super) fn clear_selected_item(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.selected_item.is_some() {
|
||||
self.selected_item = None;
|
||||
ctx.emit(InlineMenuModelEvent::UpdatedSelectedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineMenuModelEvent {
|
||||
UpdatedSelectedItem,
|
||||
UpdatedActiveTab,
|
||||
}
|
||||
|
||||
impl<A: InlineMenuAction, T: 'static + Send + Sync> Entity for InlineMenuModel<A, T> {
|
||||
type Event = InlineMenuModelEvent;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::styles::{HEADER_BORDER, HEADER_ROW_HEIGHT};
|
||||
use settings::Setting as _;
|
||||
use std::collections::HashMap;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
||||
};
|
||||
|
||||
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 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,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_VISIBLE_RESULT_COUNT: f32 = 9.;
|
||||
const MIN_VISIBLE_RESULT_COUNT: f32 = 3.;
|
||||
const MAX_VISIBLE_RESULT_COUNT: f32 = 20.;
|
||||
|
||||
/// Returns the pixel height of the content panel for a given number of visible result rows,
|
||||
/// including vertical padding and border widths.
|
||||
fn content_height_for_row_count(count: f32, appearance: &Appearance) -> f32 {
|
||||
(QUERY_RESULT_RENDERER_STYLES.result_item_height_fn)(appearance) * count
|
||||
+ (CONTENT_VERTICAL_PADDING * 2.)
|
||||
+ (CONTENT_BORDER_WIDTH * 2.)
|
||||
}
|
||||
|
||||
/// Owns the positioning and sizing state for all inline menus.
|
||||
///
|
||||
/// Computes whether the menu renders above or below the input — which depends on the user's
|
||||
/// `InputMode` setting and the available viewport space — and acts as the sole source of truth
|
||||
/// for per-menu content heights. The view never stores height; it reads from and writes to
|
||||
/// this positioner exclusively.
|
||||
pub struct InlineMenuPositioner {
|
||||
size_info: SizeInfo,
|
||||
terminal_content_position_id: String,
|
||||
/// The save position ID for the input box (prompt, editor, message bar, padding)
|
||||
/// WITHOUT the inline menu. Used to measure the input height from the previous frame
|
||||
/// for pane-aware height capping.
|
||||
input_save_position_id: String,
|
||||
window_id: WindowId,
|
||||
should_render_below_input: bool,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
/// Per-menu custom content heights, set by resize.
|
||||
custom_content_heights: HashMap<InlineMenuType, f32>,
|
||||
}
|
||||
|
||||
impl InlineMenuPositioner {
|
||||
pub fn new(
|
||||
suggestions_mode_model: &ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: &ModelHandle<AgentViewController>,
|
||||
terminal_content_position_id: String,
|
||||
input_save_position_id: String,
|
||||
size_info: SizeInfo,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let persisted_heights = InputSettings::as_ref(ctx)
|
||||
.inline_menu_custom_content_heights
|
||||
.value()
|
||||
.clone();
|
||||
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() {
|
||||
me.should_render_below_input = false;
|
||||
} else {
|
||||
match *InputModeSettings::as_ref(ctx).input_mode {
|
||||
InputMode::PinnedToBottom => {
|
||||
me.should_render_below_input = false;
|
||||
}
|
||||
InputMode::PinnedToTop => me.should_render_below_input = true,
|
||||
InputMode::Waterfall => {
|
||||
let terminal_view_height = element_size_at_last_frame(
|
||||
&me.terminal_content_position_id,
|
||||
me.window_id,
|
||||
ctx,
|
||||
)
|
||||
.map(|size| size.y())
|
||||
.unwrap_or(me.size_info.pane_height_px().as_f32());
|
||||
|
||||
me.should_render_below_input = terminal_view_height
|
||||
< me.inline_menu_height(ctx) + me.menu_frame_height(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.emit(Updated::Repositioned);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
size_info,
|
||||
terminal_content_position_id,
|
||||
input_save_position_id,
|
||||
window_id,
|
||||
suggestions_mode_model: suggestions_mode_model.clone(),
|
||||
agent_view_controller: agent_view_controller.clone(),
|
||||
should_render_below_input: false,
|
||||
custom_content_heights: persisted_heights,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the inline menu should be rendered below the input, factoring in the
|
||||
/// current input position in the viewport and the available space above/below it.
|
||||
///
|
||||
/// The preference is always to render the menu above the input, if there is available space.
|
||||
pub fn should_render_inline_menu_below_input(&self) -> bool {
|
||||
self.should_render_below_input
|
||||
}
|
||||
|
||||
/// Returns `true` if the inline menu should render results in reverse.
|
||||
///
|
||||
/// If `true`, the 0-index item in the menu is rendered at the bottom of the menu instead of the
|
||||
/// top.
|
||||
pub(super) fn should_render_results_in_reverse(&self, app: &AppContext) -> bool {
|
||||
// History menu should not reverse results - up-arrow history is always cycled
|
||||
// using the up arrow key, so the ordering should be consistent.
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_history_menu()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.should_render_below_input
|
||||
}
|
||||
|
||||
/// Returns an 'inset' to be applied to the y position of the top of the blocklist element when:
|
||||
/// * in waterfall mode, which is the only mode in which the input may be rendered in a floating
|
||||
/// position between the top and bottom of the viewport.
|
||||
/// * the inline menu is visible
|
||||
/// * the menu is rendered above the input
|
||||
///
|
||||
/// Applying this inset to the blocklist element's position simulates 'scrolling' the blocklist
|
||||
/// downwards by the height of the inline menu. Effectively, this "slides" blocklist content
|
||||
/// upwards when the menu is open.
|
||||
///
|
||||
/// This inset application is done in one of two ways in waterfall mode:
|
||||
/// * If there is no gap, we subtract the 'inset', equivalent to the inline menu
|
||||
/// height from the max height constraint of the blocklist element
|
||||
/// * If there _is_ a gap, we subtract the inset from painted visible blocklist element originj
|
||||
/// at paint time.
|
||||
///
|
||||
/// When there is no gap, that's pretty much all we need to do.
|
||||
///
|
||||
/// When there is a gap, there is further accounting to be done: this blocklist translation is
|
||||
/// applied at paint-time to reduce the surface area of logic that needs to be aware of the
|
||||
/// inline menu visiblity/height (for example, the sumtree doesn't need to be aware of this at
|
||||
/// all). This means, however, we do need to account for inset value in logic that translates
|
||||
/// artifacts of rendering (layout/paint) back to the data model -- main in places where the
|
||||
/// sumtree heights are dependent on the laid out input size (the inline menu is part of the
|
||||
/// input element subtree). The main areas considered are:
|
||||
///
|
||||
/// * Gap height calculation (which is one of the items in the sumtree)
|
||||
/// * Scroll position
|
||||
///
|
||||
/// Both of these calculations depend on the laid out input size, which must account for the
|
||||
/// visible inline menu height.
|
||||
///
|
||||
/// Here there be dragons - the key 'contract' of this method is that it returns the exact
|
||||
/// height of the inline menu when it is open and rendered above the input.
|
||||
///
|
||||
/// If and how the blocklist element logic uses this value depends on whether or not it's
|
||||
/// in waterfall mode and whether or not there is an active gap.
|
||||
pub(in crate::terminal) fn blocklist_top_inset_when_in_waterfall_mode(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<Pixels> {
|
||||
if self
|
||||
.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.is_inline_menu_open()
|
||||
{
|
||||
(!self.should_render_below_input)
|
||||
.then(|| (self.inline_menu_height(app) + self.menu_frame_height(app)).into_pixels())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when we should skip rendering the menu entirely because the pane is too short
|
||||
/// (otherwise we run into weird clipping and overflow bugs).
|
||||
pub fn should_hide_inline_menu_for_pane_size(&self, app: &AppContext) -> bool {
|
||||
let one_row_height = content_height_for_row_count(0., Appearance::as_ref(app));
|
||||
self.max_content_height_for_pane(app) < one_row_height
|
||||
}
|
||||
|
||||
/// Returns the content panel height for the currently active menu, or the default
|
||||
/// height if no custom height has been set via resize.
|
||||
///
|
||||
/// The returned height is capped so the total menu container plus the non-menu input
|
||||
/// elements do not exceed the pane height.
|
||||
pub fn inline_menu_height(&self, app: &AppContext) -> f32 {
|
||||
self.base_content_height(app)
|
||||
.min(self.max_content_height_for_pane(app))
|
||||
}
|
||||
|
||||
/// The user-customized or default content height, without any pane-aware capping.
|
||||
fn base_content_height(&self, app: &AppContext) -> f32 {
|
||||
self.suggestions_mode_model
|
||||
.as_ref(app)
|
||||
.inline_menu_type()
|
||||
.and_then(|mt| self.custom_content_heights.get(&mt).copied())
|
||||
.unwrap_or_else(|| {
|
||||
content_height_for_row_count(DEFAULT_VISIBLE_RESULT_COUNT, Appearance::as_ref(app))
|
||||
})
|
||||
}
|
||||
|
||||
/// The non-content height of the menu container (header, message bar, borders).
|
||||
fn menu_frame_height(&self, app: &AppContext) -> f32 {
|
||||
let header_height = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
HEADER_ROW_HEIGHT + HEADER_BORDER * 2.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
if self.agent_view_controller.as_ref(app).is_active() {
|
||||
header_height
|
||||
} else {
|
||||
header_height + standard_message_bar_height(app) + INLINE_MENU_BORDER_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a resize delta to the given menu type's content height, clamping to the
|
||||
/// allowed min/max range. Returns the actual height change applied, or `None` if the
|
||||
/// delta had no effect.
|
||||
pub fn apply_resize_delta(
|
||||
&mut self,
|
||||
menu_type: InlineMenuType,
|
||||
delta: f32,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Option<f32> {
|
||||
let current = self.inline_menu_height(ctx);
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
|
||||
let min_height = content_height_for_row_count(MIN_VISIBLE_RESULT_COUNT, appearance);
|
||||
let max_height = content_height_for_row_count(MAX_VISIBLE_RESULT_COUNT, appearance)
|
||||
.min(self.max_content_height_for_pane(ctx))
|
||||
.max(min_height);
|
||||
|
||||
// When the menu renders above input, the header is at the top — dragging
|
||||
// down (positive delta) shrinks the menu. When below, the header is at
|
||||
// the bottom — dragging down grows the menu.
|
||||
let new_height = if self.should_render_below_input {
|
||||
current + delta
|
||||
} else {
|
||||
current - delta
|
||||
}
|
||||
.clamp(min_height, max_height);
|
||||
let height_change = new_height - current;
|
||||
if height_change.abs() <= f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.custom_content_heights.insert(menu_type, new_height);
|
||||
|
||||
ctx.emit(Updated::Resized);
|
||||
Some(height_change)
|
||||
}
|
||||
|
||||
/// Persists the current custom content heights to settings.
|
||||
/// Call this when a resize drag finishes rather than on every drag event.
|
||||
pub fn persist_custom_content_heights(&self, ctx: &mut ModelContext<Self>) {
|
||||
InputSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings
|
||||
.inline_menu_custom_content_heights
|
||||
.set_value(self.custom_content_heights.clone(), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates the cached pane size info.
|
||||
pub fn set_size_info(&mut self, size_info: SizeInfo, ctx: &mut ModelContext<Self>) {
|
||||
let height_changed =
|
||||
(self.size_info.pane_height_px().as_f32() - size_info.pane_height_px().as_f32()).abs()
|
||||
> f32::EPSILON;
|
||||
self.size_info = size_info;
|
||||
if height_changed {
|
||||
ctx.emit(Updated::Resized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximum content panel height that keeps the entire menu container
|
||||
/// plus the non-menu input elements within the pane height.
|
||||
fn max_content_height_for_pane(&self, app: &AppContext) -> f32 {
|
||||
let pane_height = self.size_info.pane_height_px().as_f32();
|
||||
let overhead = self.menu_frame_height(app);
|
||||
|
||||
let input_box_height =
|
||||
element_size_at_last_frame(&self.input_save_position_id, self.window_id, app)
|
||||
.map(|size| size.y())
|
||||
.unwrap_or(0.);
|
||||
|
||||
// We clamp to 0 rather than to MIN_VISIBLE_RESULT_COUNT so that, if the pane is too
|
||||
// short to fit even a minimal menu, we still let the menu height shrink to nothing
|
||||
// rather than overflowing into an adjacent pane.
|
||||
(pane_height - input_box_height - overhead).max(0.)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Updated {
|
||||
Repositioned,
|
||||
Resized,
|
||||
}
|
||||
|
||||
impl Entity for InlineMenuPositioner {
|
||||
type Event = Updated;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Shared styling utilities for inline menu implementations.
|
||||
//!
|
||||
//! This module provides common styling functions used across all inline menu
|
||||
//! implementations (models, slash commands, conversations) to ensure consistent
|
||||
//! visual design matching the Figma specifications.
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::color::blend::Blend;
|
||||
use warp_core::ui::theme::{Fill, WarpTheme};
|
||||
use warpui::color::ColorU;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_fill;
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
|
||||
/// Font size used for inline menu items.
|
||||
pub fn font_size(appearance: &Appearance) -> f32 {
|
||||
appearance.monospace_font_size() - 2.
|
||||
}
|
||||
|
||||
pub const ICON_MARGIN: f32 = 8.0;
|
||||
pub const ITEM_HORIZONTAL_PADDING: f32 = 8.0;
|
||||
pub const ITEM_CORNER_RADIUS: f32 = 4.0;
|
||||
pub const CONTENT_VERTICAL_PADDING: f32 = 8.;
|
||||
pub const CONTENT_BORDER_WIDTH: f32 = 1.;
|
||||
|
||||
/// Height of the header row content, ensuring all headers render at the same
|
||||
/// height regardless of whether tabs or trailing elements are present.
|
||||
pub const HEADER_ROW_HEIGHT: f32 = 24.;
|
||||
pub const HEADER_BORDER: f32 = 1.;
|
||||
|
||||
pub fn menu_background_color(app: &AppContext) -> ColorU {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
theme.background().blend(&agent_view_bg_fill(app)).into()
|
||||
}
|
||||
|
||||
pub fn item_background(
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
let theme = appearance.theme();
|
||||
match highlight_state {
|
||||
ItemHighlightState::Selected { .. } => Some(theme.surface_overlay_2()),
|
||||
ItemHighlightState::Hovered => Some(theme.surface_overlay_1()),
|
||||
ItemHighlightState::Default => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.main_text_color(background)
|
||||
}
|
||||
|
||||
pub fn secondary_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.sub_text_color(background)
|
||||
}
|
||||
|
||||
pub fn disabled_text_color(theme: &WarpTheme, background: Fill) -> Fill {
|
||||
theme.disabled_text_color(background)
|
||||
}
|
||||
|
||||
pub fn icon_color(appearance: &Appearance) -> Fill {
|
||||
let theme = appearance.theme();
|
||||
theme.sub_text_color(theme.background()).with_opacity(80)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
//! Shared message producers for displaying attached blocks/text context.
|
||||
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::ai::blocklist::agent_view::{AgentMessageBarMouseStates, AgentViewController};
|
||||
use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputModel};
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::message_bar::{
|
||||
truncated_command_for_block, Message, MessageItem, MessageProvider,
|
||||
};
|
||||
use crate::terminal::input::InputAction;
|
||||
use crate::terminal::model::TerminalModel;
|
||||
|
||||
/// Trait for message args that can provide attached context information.
|
||||
/// Exposes the required dependencies for attached context message producers.
|
||||
pub trait AttachedContextArgs {
|
||||
fn terminal_model(&self) -> &TerminalModel;
|
||||
fn input_buffer_model(&self) -> &InputBufferModel;
|
||||
fn input_model(&self) -> &BlocklistAIInputModel;
|
||||
fn agent_view_controller(&self) -> &AgentViewController;
|
||||
fn context_model(&self) -> &BlocklistAIContextModel;
|
||||
fn mouse_states(&self) -> &AgentMessageBarMouseStates;
|
||||
}
|
||||
|
||||
/// Produces a message when blocks or selected text are attached as context.
|
||||
pub struct AttachedBlocksMessageProducer;
|
||||
|
||||
impl<Args: AttachedContextArgs + Copy> MessageProvider<Args> for AttachedBlocksMessageProducer {
|
||||
fn produce_message(&self, args: Args) -> Option<Message> {
|
||||
// When AgentViewBlockContext is enabled, user-executed blocks are auto-attached
|
||||
// as context, so we don't need to show this message.
|
||||
if FeatureFlag::AgentViewBlockContext.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// In the agent view, only show the attached context message if in AI mode.
|
||||
if args.agent_view_controller().is_active()
|
||||
&& !args.input_buffer_model().current_value().is_empty()
|
||||
&& !args.input_model().is_ai_input_enabled()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let context_block_ids = args.context_model().pending_context_block_ids();
|
||||
if context_block_ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let block_command = context_block_ids
|
||||
.iter()
|
||||
.find_map(|id| {
|
||||
args.terminal_model()
|
||||
.block_list()
|
||||
.block_with_id(id)
|
||||
.map(|block| block.command_to_string())
|
||||
})
|
||||
.map(|cmd| truncated_command_for_block(&cmd))?;
|
||||
|
||||
let message_text = if context_block_ids.len() == 1 {
|
||||
format!("`{}` attached as context", block_command)
|
||||
} else if context_block_ids.len() == 2 {
|
||||
format!(
|
||||
"`{}` and 1 other command attached as context",
|
||||
block_command
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"`{}` and {} other commands attached as context",
|
||||
block_command,
|
||||
context_block_ids.len().saturating_sub(1)
|
||||
)
|
||||
};
|
||||
|
||||
let mut items = vec![MessageItem::text(message_text)];
|
||||
|
||||
// Always show ESC hint in agent view, make it clickable
|
||||
if args.agent_view_controller().is_active() {
|
||||
items.push(MessageItem::text(", "));
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to remove"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ClearAttachedContext);
|
||||
},
|
||||
args.mouse_states().clear_attached_context.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a message when text selection is attached as context.
|
||||
pub struct AttachedTextSelectionMessageProducer;
|
||||
|
||||
impl<Args: AttachedContextArgs + Copy> MessageProvider<Args>
|
||||
for AttachedTextSelectionMessageProducer
|
||||
{
|
||||
fn produce_message(&self, args: Args) -> Option<Message> {
|
||||
// Only apply the visibility condition when agent view is active.
|
||||
// When inactive, always show the message.
|
||||
if args.agent_view_controller().is_active()
|
||||
&& !args.input_buffer_model().current_value().is_empty()
|
||||
&& !args.input_model().is_ai_input_enabled()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only show if there's selected text and no blocks attached
|
||||
// (blocks take precedence per requirements)
|
||||
if !args.context_model().pending_context_block_ids().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let _ = args.context_model().pending_context_selected_text()?;
|
||||
|
||||
let mut items = vec![MessageItem::text("selected text attached as context")];
|
||||
|
||||
// Always show ESC hint in agent view, make it clickable
|
||||
if args.agent_view_controller().is_active() {
|
||||
items.push(MessageItem::text(", "));
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to remove"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ClearAttachedContext);
|
||||
},
|
||||
args.mouse_states().clear_attached_context.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
use crate::ai::blocklist::agent_view::agent_view_bg_color;
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{
|
||||
Border, CacheOption, Clipped, Container, CornerRadius, Element, Hoverable, Image,
|
||||
ParentElement, Radius,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::prelude::{Align, ConstrainedBox, CrossAxisAlignment, Flex, Text};
|
||||
use warpui::ui_components::keyboard_shortcut::keystroke_to_keys;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::shortcuts::render_keystroke_with_color_overrides;
|
||||
use crate::terminal;
|
||||
use crate::terminal::input::message_bar::{ChipHorizontalAlignment, Message, MessageItem};
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn standard_message_bar_height(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
app.font_cache()
|
||||
.line_height(styles::font_size(app), appearance.line_height_ratio())
|
||||
+ styles::VERTICAL_PADDING * 2.
|
||||
}
|
||||
|
||||
pub fn render_standard_message_bar(
|
||||
message: Message,
|
||||
right_element: Option<Box<dyn Element>>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use warpui::prelude::{MainAxisAlignment, MainAxisSize};
|
||||
|
||||
let (left_items, right_chips): (Vec<_>, Vec<_>) = message.items.into_iter().partition(|item| {
|
||||
!matches!(
|
||||
item,
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment: ChipHorizontalAlignment::Right,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
let right_element = if right_chips.is_empty() {
|
||||
right_element
|
||||
} else {
|
||||
let right_chips_element = render_message_bar_items(&right_chips, app);
|
||||
Some(if let Some(existing_right) = right_element {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(right_chips_element)
|
||||
.with_child(existing_right)
|
||||
.finish()
|
||||
} else {
|
||||
right_chips_element
|
||||
})
|
||||
};
|
||||
|
||||
let content = if let Some(right_element) = right_element {
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(render_message_bar_items(&left_items, app))
|
||||
.with_child(right_element)
|
||||
.finish()
|
||||
} else {
|
||||
Align::new(render_message_bar_items(&left_items, app))
|
||||
.left()
|
||||
.finish()
|
||||
};
|
||||
|
||||
ConstrainedBox::new(
|
||||
Clipped::new(
|
||||
Container::new(content)
|
||||
.with_horizontal_padding(*terminal::view::PADDING_LEFT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(standard_message_bar_height(app))
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_standard_message(message: Message, app: &AppContext) -> Box<dyn Element> {
|
||||
render_message_bar_items(&message.items, app)
|
||||
}
|
||||
|
||||
/// Renders a sequence of message items into a flex row.
|
||||
/// Currently used for agent message bar items and zero state message bar items.
|
||||
fn render_message_bar_items(items: &[MessageItem], app: &AppContext) -> Box<dyn Element> {
|
||||
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);
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let mut child: Box<dyn Element> = match item {
|
||||
MessageItem::Keystroke {
|
||||
keystroke,
|
||||
color,
|
||||
background_color,
|
||||
} => Container::new(render_keystroke_with_color_overrides(
|
||||
keystroke,
|
||||
*color,
|
||||
*background_color,
|
||||
app,
|
||||
))
|
||||
.finish(),
|
||||
MessageItem::Text { content, color } => {
|
||||
let font_color = color.unwrap_or(default_font_color);
|
||||
Text::new(
|
||||
content.clone(),
|
||||
appearance.ui_font_family(),
|
||||
styles::font_size(app),
|
||||
)
|
||||
.with_color(font_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Hyperlink {
|
||||
content,
|
||||
url,
|
||||
color,
|
||||
mouse_state,
|
||||
} => {
|
||||
let link_color = color.unwrap_or_else(|| appearance.theme().accent().into());
|
||||
let content = content.clone();
|
||||
let url = url.clone();
|
||||
Hoverable::new(mouse_state.clone(), |_| {
|
||||
Text::new(
|
||||
content.clone(),
|
||||
appearance.ui_font_family(),
|
||||
styles::font_size(app),
|
||||
)
|
||||
.with_color(link_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |_, app, _| {
|
||||
app.open_url(&url);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Icon { icon, color } => {
|
||||
let icon_color = color.unwrap_or(default_font_color);
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(styles::font_size(app))
|
||||
.with_width(styles::font_size(app))
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Clickable {
|
||||
items: clickable_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
} => {
|
||||
let message_items = render_message_bar_items(clickable_items, app);
|
||||
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |_| message_items)
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
message_items
|
||||
}
|
||||
}
|
||||
MessageItem::Chip {
|
||||
items: chip_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
..
|
||||
} => {
|
||||
let chip_items = chip_items.clone();
|
||||
let action = action.clone();
|
||||
if !disabled {
|
||||
Hoverable::new(mouse_state.clone(), move |state| {
|
||||
render_message_chip_container(
|
||||
render_message_bar_items(&chip_items, app),
|
||||
state.is_hovered(),
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
render_message_chip_container(
|
||||
render_message_bar_items(chip_items.as_slice(), app),
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => ConstrainedBox::new(Image::new(source.clone(), CacheOption::BySize).finish())
|
||||
.with_width(*width)
|
||||
.with_height(*height)
|
||||
.finish(),
|
||||
};
|
||||
if i < items.len() - 1 {
|
||||
let next_item = &items[i + 1];
|
||||
let should_space_inline = matches!(item, MessageItem::Text { content, .. } | MessageItem::Hyperlink { content, .. } if content.ends_with(' '))
|
||||
|| matches!(next_item, MessageItem::Text { content, .. } | MessageItem::Hyperlink { content, .. } if content.starts_with(' '));
|
||||
|
||||
if !should_space_inline {
|
||||
let spacing = match (item, next_item) {
|
||||
(
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
MessageItem::Text { .. } | MessageItem::Hyperlink { .. },
|
||||
)
|
||||
| (
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
) => Some(4.0),
|
||||
(
|
||||
MessageItem::Text { .. } | MessageItem::Hyperlink { .. },
|
||||
MessageItem::Keystroke { .. }
|
||||
| MessageItem::Icon { .. }
|
||||
| MessageItem::Image { .. },
|
||||
)
|
||||
| (_, MessageItem::Clickable { .. } | MessageItem::Chip { .. })
|
||||
| (MessageItem::Clickable { .. } | MessageItem::Chip { .. }, _) => Some(8.0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(spacing) = spacing {
|
||||
child = Container::new(child).with_margin_right(spacing).finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row.add_child(child);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
pub fn render_terminal_message(message: Message, app: &AppContext) -> Box<dyn Element> {
|
||||
render_terminal_message_items(&message.items, app)
|
||||
}
|
||||
|
||||
/// Renders a sequence of message items into a flex row with terminal-specific styling.
|
||||
/// Currently used for terminal message bar items and use agent footer message items.
|
||||
fn render_terminal_message_items(items: &[MessageItem], app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let default_text_color = theme.disabled_text_color(theme.background()).into_solid();
|
||||
let font_family = appearance.monospace_font_family();
|
||||
let font_size = appearance.monospace_font_size() - 2.;
|
||||
let icon_size = font_size;
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_constrain_horizontal_bounds_to_parent(true);
|
||||
|
||||
for item in items {
|
||||
let child = match item {
|
||||
MessageItem::Keystroke {
|
||||
keystroke, color, ..
|
||||
} => {
|
||||
let keystroke_color = color.unwrap_or(default_text_color);
|
||||
let keys = keystroke_to_keys(keystroke);
|
||||
let mut key_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
let mut key_text = key.text(true);
|
||||
let rendered_key = if key_text == "⏎" {
|
||||
ConstrainedBox::new(
|
||||
Icon::CornerDownLeft
|
||||
.to_warpui_icon(Fill::Solid(keystroke_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
} else if key_text == "⇧" {
|
||||
ConstrainedBox::new(
|
||||
Icon::ArrowBlockUp
|
||||
.to_warpui_icon(Fill::Solid(keystroke_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
} else {
|
||||
if !cfg!(target_os = "macos") && i < (keys.len() - 1) {
|
||||
key_text = format!("{key_text}-").into();
|
||||
}
|
||||
Text::new(key_text, font_family, icon_size)
|
||||
.with_color(keystroke_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
};
|
||||
|
||||
if i == 0 {
|
||||
key_row.add_child(rendered_key);
|
||||
} else {
|
||||
key_row
|
||||
.add_child(Container::new(rendered_key).with_margin_left(2.).finish());
|
||||
}
|
||||
}
|
||||
|
||||
key_row.finish()
|
||||
}
|
||||
MessageItem::Text { content, color } => {
|
||||
let text_color = color.unwrap_or(default_text_color);
|
||||
Text::new(content.clone(), font_family, font_size)
|
||||
.with_color(text_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Hyperlink {
|
||||
content,
|
||||
url,
|
||||
color,
|
||||
mouse_state,
|
||||
} => {
|
||||
let link_color = color.unwrap_or_else(|| theme.accent().into());
|
||||
let content = content.clone();
|
||||
let url = url.clone();
|
||||
Hoverable::new(mouse_state.clone(), |_| {
|
||||
Text::new(content.clone(), font_family, font_size)
|
||||
.with_color(link_color)
|
||||
.soft_wrap(false)
|
||||
.finish()
|
||||
})
|
||||
.on_click(move |_, app, _| {
|
||||
app.open_url(&url);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Icon { icon, color } => {
|
||||
let icon_color = color.unwrap_or(default_text_color);
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(icon_color)).finish())
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
}
|
||||
MessageItem::Clickable {
|
||||
items: clickable_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
} => {
|
||||
let message_items = render_terminal_message_items(clickable_items, app);
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |_| message_items)
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
message_items
|
||||
}
|
||||
}
|
||||
MessageItem::Chip {
|
||||
items: chip_items,
|
||||
action,
|
||||
mouse_state,
|
||||
disabled,
|
||||
..
|
||||
} => {
|
||||
let chip_items = chip_items.clone();
|
||||
if !disabled {
|
||||
let action = action.clone();
|
||||
Hoverable::new(mouse_state.clone(), move |state| {
|
||||
render_message_chip_container(
|
||||
render_terminal_message_items(&chip_items, app),
|
||||
state.is_hovered(),
|
||||
app,
|
||||
)
|
||||
})
|
||||
.on_click(move |ctx, _app, _pos| {
|
||||
action(ctx);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
render_message_chip_container(
|
||||
render_terminal_message_items(chip_items.as_slice(), app),
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => ConstrainedBox::new(Image::new(source.clone(), CacheOption::BySize).finish())
|
||||
.with_width(*width)
|
||||
.with_height(*height)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
row.add_child(child);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_message_chip_container(
|
||||
content: Box<dyn Element>,
|
||||
is_hovered: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let background = if is_hovered {
|
||||
blended_colors::fg_overlay_2(theme)
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(theme)
|
||||
};
|
||||
|
||||
Container::new(content)
|
||||
.with_background(background)
|
||||
.with_border(
|
||||
Border::all(styles::CHIP_BORDER_WIDTH)
|
||||
.with_border_color(blended_colors::neutral_3(theme)),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
styles::CHIP_CORNER_RADIUS,
|
||||
)))
|
||||
.with_vertical_padding(styles::CHIP_VERTICAL_PADDING)
|
||||
.with_horizontal_padding(styles::CHIP_HORIZONTAL_PADDING)
|
||||
.with_vertical_margin(-(styles::CHIP_VERTICAL_PADDING + styles::CHIP_BORDER_WIDTH))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns the background and foreground colors for a message item that can be disabled.
|
||||
pub fn disableable_message_item_color_overrides(
|
||||
is_disabled: bool,
|
||||
app: &AppContext,
|
||||
) -> (Option<ColorU>, Option<ColorU>) {
|
||||
if !is_disabled {
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
(
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(agent_view_bg_color(app).into())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_2(appearance.theme())),
|
||||
)
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
pub fn font_size(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.monospace_font_size() - 2.
|
||||
}
|
||||
|
||||
pub fn default_font_color(app: &AppContext) -> ColorU {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
theme
|
||||
.sub_text_color(blended_colors::neutral_1(theme).into())
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
pub const VERTICAL_PADDING: f32 = 8.;
|
||||
pub const CHIP_CORNER_RADIUS: f32 = 2.;
|
||||
pub const CHIP_BORDER_WIDTH: f32 = 1.;
|
||||
pub const CHIP_VERTICAL_PADDING: f32 = 2.;
|
||||
pub const CHIP_HORIZONTAL_PADDING: f32 = 4.;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Shared types for message bar rendering across terminal and agent views.
|
||||
pub mod attached_context;
|
||||
pub mod common;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::EventContext;
|
||||
/// A trait for types that can produce a message based on some contextual arguments.
|
||||
///
|
||||
/// The generic `Args` parameter allows each call site to define its own
|
||||
/// context-specific arguments (e.g., terminal-specific or agent-specific state).
|
||||
pub trait MessageProvider<Args> {
|
||||
/// Produces a message based on the given arguments, or `None` if this
|
||||
/// provider does not apply in the current context.
|
||||
fn produce_message(&self, args: Args) -> Option<Message>;
|
||||
}
|
||||
|
||||
/// A trait for types that can transform an existing message based on some contextual arguments.
|
||||
pub trait MessageTransformer<Args> {
|
||||
/// Modifies the given message.
|
||||
///
|
||||
/// Returns `true` if the message was indeed modified.
|
||||
fn transform_message(&self, message: &mut Message, args: Args) -> bool;
|
||||
}
|
||||
|
||||
/// A message to be displayed in a message bar, composed of a sequence of items.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Message {
|
||||
/// The items that make up this message.
|
||||
pub items: Vec<MessageItem>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Creates a new message with the given items.
|
||||
pub fn new(items: Vec<MessageItem>) -> Self {
|
||||
Self { items }
|
||||
}
|
||||
|
||||
/// Creates a message from a single text string.
|
||||
pub fn from_text(text: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self {
|
||||
items: vec![MessageItem::text(text)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends text to the last item if it's a Text item, otherwise adds a new text item.
|
||||
pub fn append_text(&mut self, text: &str) {
|
||||
if let Some(MessageItem::Text { content, .. }) = self.items.last_mut() {
|
||||
*content = Cow::Owned(format!("{}{}", content, text));
|
||||
} else {
|
||||
self.items.push(MessageItem::text(text.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the color override for all text items in the message.
|
||||
pub fn with_text_color(mut self, color: ColorU) -> Self {
|
||||
for item in &mut self.items {
|
||||
item.set_text_color(color);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color override for all items in the message.
|
||||
pub fn with_color(mut self, color: ColorU) -> Self {
|
||||
self.set_color(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color override for all items in the message.
|
||||
pub fn set_color(&mut self, color: ColorU) {
|
||||
for item in &mut self.items {
|
||||
item.set_color(color);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_items(self) -> Vec<MessageItem> {
|
||||
self.items
|
||||
}
|
||||
}
|
||||
|
||||
pub type ClickHandler = Arc<dyn Fn(&mut EventContext) + 'static>;
|
||||
|
||||
/// Horizontal alignment for chip items in the message bar.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
|
||||
pub enum ChipHorizontalAlignment {
|
||||
/// Align the chip to the left (default behavior).
|
||||
#[default]
|
||||
Left,
|
||||
/// Align the chip to the right of the message bar.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// An item within a message, either a keystroke, text, icon, or interactive group.
|
||||
#[derive(Clone)]
|
||||
pub enum MessageItem {
|
||||
/// A keyboard shortcut/keystroke to display.
|
||||
Keystroke {
|
||||
/// The keystroke to display.
|
||||
keystroke: Keystroke,
|
||||
/// Optional color override for this keystroke.
|
||||
color: Option<ColorU>,
|
||||
/// Optional background color override for this keystroke,
|
||||
background_color: Option<ColorU>,
|
||||
},
|
||||
/// Text content with an optional color override.
|
||||
Text {
|
||||
/// The text content.
|
||||
content: Cow<'static, str>,
|
||||
/// Optional color override for this text.
|
||||
color: Option<ColorU>,
|
||||
},
|
||||
/// A hyperlink that opens a URL.
|
||||
Hyperlink {
|
||||
/// The link text.
|
||||
content: Cow<'static, str>,
|
||||
/// The target URL.
|
||||
url: String,
|
||||
/// Optional color override for this link.
|
||||
color: Option<ColorU>,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
},
|
||||
/// An icon with an optional color override.
|
||||
Icon {
|
||||
/// The icon to display.
|
||||
icon: Icon,
|
||||
/// Optional color override for this icon.
|
||||
color: Option<ColorU>,
|
||||
},
|
||||
/// A clickable group of items.
|
||||
Clickable {
|
||||
/// The items in this clickable group.
|
||||
items: Vec<MessageItem>,
|
||||
/// Click handler for the group.
|
||||
action: ClickHandler,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
/// Whether this clickable is disabled (no hover/click behavior).
|
||||
disabled: bool,
|
||||
},
|
||||
/// A clickable chip group of items with container styling handled by renderers.
|
||||
Chip {
|
||||
/// The items rendered inside this chip.
|
||||
items: Vec<MessageItem>,
|
||||
/// Click handler for the chip.
|
||||
action: ClickHandler,
|
||||
/// Mouse state handle for hover/click tracking.
|
||||
mouse_state: MouseStateHandle,
|
||||
/// Whether this chip is disabled (no hover/click behavior).
|
||||
disabled: bool,
|
||||
/// Horizontal alignment of the chip within the message bar.
|
||||
horizontal_alignment: ChipHorizontalAlignment,
|
||||
},
|
||||
/// A raw image rendered from an asset source at a specific size.
|
||||
/// Unlike `Icon`, this preserves the original colors of the asset (e.g. colored SVGs).
|
||||
Image {
|
||||
/// The asset source for the image.
|
||||
source: AssetSource,
|
||||
/// Display width in logical pixels.
|
||||
width: f32,
|
||||
/// Display height in logical pixels.
|
||||
height: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MessageItem {
|
||||
/// Creates a keystroke item from a `Keystroke`.
|
||||
pub fn keystroke(keystroke: Keystroke) -> Self {
|
||||
Self::Keystroke {
|
||||
keystroke,
|
||||
color: None,
|
||||
background_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a text item with no color override.
|
||||
pub fn text(content: impl Into<Cow<'static, str>>) -> Self {
|
||||
Self::Text {
|
||||
content: content.into(),
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a hyperlink item.
|
||||
pub fn hyperlink(
|
||||
content: impl Into<Cow<'static, str>>,
|
||||
url: String,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
Self::Hyperlink {
|
||||
content: content.into(),
|
||||
url,
|
||||
color: None,
|
||||
mouse_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an icon item with no color override.
|
||||
pub fn icon(icon: Icon) -> Self {
|
||||
Self::Icon { icon, color: None }
|
||||
}
|
||||
|
||||
/// Creates an image item from an asset source at the given size.
|
||||
pub fn image(source: AssetSource, width: f32, height: f32) -> Self {
|
||||
Self::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a clickable group of items.
|
||||
pub fn clickable(
|
||||
items: Vec<MessageItem>,
|
||||
action: impl Fn(&mut EventContext) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!items.iter().any(|i| matches!(i, Self::Clickable { .. })),
|
||||
"Nested clickable message items are not supported"
|
||||
);
|
||||
Self::Clickable {
|
||||
items,
|
||||
action: Arc::new(action),
|
||||
mouse_state,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a clickable chip group of items.
|
||||
pub fn chip(
|
||||
items: Vec<MessageItem>,
|
||||
action: impl Fn(&mut EventContext) + 'static,
|
||||
mouse_state: MouseStateHandle,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|i| matches!(i, Self::Clickable { .. } | Self::Chip { .. })),
|
||||
"Nested interactive message items are not supported"
|
||||
);
|
||||
Self::Chip {
|
||||
items,
|
||||
action: Arc::new(action),
|
||||
mouse_state,
|
||||
disabled: false,
|
||||
horizontal_alignment: ChipHorizontalAlignment::default(),
|
||||
}
|
||||
}
|
||||
/// Sets the color for text items.
|
||||
pub fn set_text_color(&mut self, color: ColorU) {
|
||||
match self {
|
||||
Self::Text {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Hyperlink {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Clickable { items, .. } | Self::Chip { items, .. } => {
|
||||
for item in items {
|
||||
item.set_text_color(color);
|
||||
}
|
||||
}
|
||||
Self::Keystroke { .. } | Self::Icon { .. } | Self::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the color for all items.
|
||||
pub fn set_color(&mut self, color: ColorU) {
|
||||
match self {
|
||||
Self::Text {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Hyperlink {
|
||||
color: item_color, ..
|
||||
}
|
||||
| Self::Keystroke {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Clickable { items, .. } | Self::Chip { items, .. } => {
|
||||
for item in items {
|
||||
item.set_color(color);
|
||||
}
|
||||
}
|
||||
Self::Icon {
|
||||
color: item_color, ..
|
||||
} => {
|
||||
*item_color = Some(color);
|
||||
}
|
||||
Self::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets disabled state for clickable items.
|
||||
/// This only affects interactivity (hover/click behavior).
|
||||
/// For visual styling, use `set_color()` or `set_text_color()`.
|
||||
pub fn set_is_disabled(&mut self, is_disabled: bool) {
|
||||
match self {
|
||||
Self::Clickable {
|
||||
disabled, items, ..
|
||||
}
|
||||
| Self::Chip {
|
||||
disabled, items, ..
|
||||
} => {
|
||||
*disabled = is_disabled;
|
||||
for item in items {
|
||||
item.set_is_disabled(is_disabled);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets disabled state for clickable items and returns self.
|
||||
pub fn with_is_disabled(mut self, is_disabled: bool) -> Self {
|
||||
self.set_is_disabled(is_disabled);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the horizontal alignment for chip items and returns self.
|
||||
/// Has no effect on non-chip items.
|
||||
pub fn with_horizontal_alignment(mut self, alignment: ChipHorizontalAlignment) -> Self {
|
||||
if let Self::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*horizontal_alignment = alignment;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmptyMessageProducer;
|
||||
|
||||
impl<T> MessageProvider<T> for EmptyMessageProducer {
|
||||
fn produce_message(&self, _: T) -> Option<Message> {
|
||||
Some(Message::from_text(""))
|
||||
}
|
||||
}
|
||||
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
|
||||
/// Returns a truncated command string for display in message bars.
|
||||
/// Limits to 27 characters (including ellipsis) if truncated.
|
||||
pub fn truncated_command_for_block(command: &str) -> String {
|
||||
truncate_from_end(command, 27)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
|
||||
use super::{ChipHorizontalAlignment, MessageItem};
|
||||
|
||||
#[test]
|
||||
fn chip_constructor_creates_enabled_chip_with_items() {
|
||||
let item = MessageItem::chip(
|
||||
vec![
|
||||
MessageItem::text("hello"),
|
||||
MessageItem::keystroke(Default::default()),
|
||||
],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
items, disabled, ..
|
||||
} => {
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(!disabled);
|
||||
}
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_constructor_creates_icon_without_color_override() {
|
||||
let item = MessageItem::icon(Icon::NewConversation);
|
||||
|
||||
match item {
|
||||
MessageItem::Icon { icon, color } => {
|
||||
assert!(matches!(icon, Icon::NewConversation));
|
||||
assert!(color.is_none());
|
||||
}
|
||||
_ => panic!("expected icon variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_color_sets_icon_color_override() {
|
||||
let color = pathfinder_color::ColorU {
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 255,
|
||||
};
|
||||
let mut item = MessageItem::icon(Icon::MessagePlusSquare);
|
||||
item.set_color(color);
|
||||
|
||||
match item {
|
||||
MessageItem::Icon {
|
||||
color: Some(item_color),
|
||||
..
|
||||
} => assert_eq!(item_color, color),
|
||||
_ => panic!("expected icon variant with color override"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_constructor_creates_image_with_correct_dimensions() {
|
||||
let item = MessageItem::image(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/test.svg",
|
||||
},
|
||||
16.,
|
||||
16.,
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Image {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
assert!(matches!(
|
||||
source,
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/test.svg"
|
||||
}
|
||||
));
|
||||
assert_eq!(width, 16.);
|
||||
assert_eq!(height, 16.);
|
||||
}
|
||||
_ => panic!("expected image variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_is_disabled_marks_chip_as_disabled() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)
|
||||
.with_is_disabled(true);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip { disabled, .. } => assert!(disabled),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chip_constructor_defaults_to_left_alignment() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} => assert_eq!(horizontal_alignment, ChipHorizontalAlignment::Left),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_horizontal_alignment_sets_right_alignment() {
|
||||
let item = MessageItem::chip(
|
||||
vec![MessageItem::text("hello")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)
|
||||
.with_horizontal_alignment(ChipHorizontalAlignment::Right);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
horizontal_alignment,
|
||||
..
|
||||
} => assert_eq!(horizontal_alignment, ChipHorizontalAlignment::Right),
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_disabled_propagates_to_nested_interactive_items() {
|
||||
let mut item = MessageItem::Chip {
|
||||
items: vec![MessageItem::clickable(
|
||||
vec![MessageItem::text("nested")],
|
||||
|_ctx| {},
|
||||
MouseStateHandle::default(),
|
||||
)],
|
||||
action: std::sync::Arc::new(|_ctx| {}),
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
disabled: false,
|
||||
horizontal_alignment: ChipHorizontalAlignment::default(),
|
||||
};
|
||||
item.set_is_disabled(true);
|
||||
|
||||
match item {
|
||||
MessageItem::Chip {
|
||||
disabled, items, ..
|
||||
} => {
|
||||
assert!(disabled);
|
||||
match &items[0] {
|
||||
MessageItem::Clickable { disabled, .. } => assert!(*disabled),
|
||||
_ => panic!("expected nested clickable item"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected chip variant"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, FormattedTextElement, Highlight, HighlightedHyperlink,
|
||||
MouseStateHandle, Radius, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, EntityId, SingletonEntity as _};
|
||||
|
||||
use crate::ai::llms::{
|
||||
is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo, LLMPreferences, LLMProvider,
|
||||
LLMSpec,
|
||||
};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::data_source::{Query, QueryFilter, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
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,
|
||||
};
|
||||
use crate::terminal::input::inline_menu::{styles as inline_styles, DetailsRenderConfig};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::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,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptModel {
|
||||
pub id: LLMId,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptModel {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ModelSelector;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
if !FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
return Some(Message::new(default_navigation_message_items(&args)));
|
||||
}
|
||||
|
||||
let mut items = vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to select"),
|
||||
MessageItem::keystroke(if OperatingSystem::get().is_mac() {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
MessageItem::text(" select and save to profile"),
|
||||
];
|
||||
|
||||
if args.inline_menu_model.tab_configs().len() > 1 {
|
||||
items.push(MessageItem::keystroke(Keystroke {
|
||||
key: "tab".to_owned(),
|
||||
shift: true,
|
||||
..Default::default()
|
||||
}));
|
||||
items.push(MessageItem::text(" to cycle tabs"));
|
||||
}
|
||||
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "escape".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" to dismiss"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
crate::terminal::input::inline_menu::InlineMenuRowAction::<Self>::Dismiss,
|
||||
);
|
||||
},
|
||||
args.inline_menu_model.mouse_states().dismiss.clone(),
|
||||
));
|
||||
|
||||
Some(Message::new(items))
|
||||
}
|
||||
|
||||
fn details_render_config(app: &AppContext) -> Option<DetailsRenderConfig> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let max_item_width = app.font_cache().em_width(
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
) * 40.;
|
||||
Some(DetailsRenderConfig {
|
||||
min_required_details_width: Some(model_specs_width(app)),
|
||||
max_result_width: Some(max_item_width),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn model_specs_width(app: &AppContext) -> f32 {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
app.font_cache().em_width(
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
) * 34.
|
||||
}
|
||||
|
||||
pub struct ModelSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
impl ModelSelectorDataSource {
|
||||
pub fn new(terminal_view_id: EntityId) -> Self {
|
||||
Self { terminal_view_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ModelSelectorDataSource {
|
||||
type Action = AcceptModel;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let llm_preferences = LLMPreferences::as_ref(app);
|
||||
let is_full_terminal = query.filters.contains(&QueryFilter::FullTerminalUseModels);
|
||||
|
||||
let active_llm_id = if is_full_terminal {
|
||||
llm_preferences
|
||||
.get_active_cli_agent_model(app, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone()
|
||||
} else {
|
||||
llm_preferences
|
||||
.get_active_base_model(app, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone()
|
||||
};
|
||||
|
||||
let choices: Vec<&LLMInfo> = if is_full_terminal {
|
||||
llm_preferences.get_cli_agent_llm_choices().collect_vec()
|
||||
} else {
|
||||
llm_preferences
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.collect_vec()
|
||||
};
|
||||
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
if query_text.is_empty() {
|
||||
return Ok(choices
|
||||
.into_iter()
|
||||
.map(|llm| QueryResult::from(ModelSearchItem::new(llm, &active_llm_id, app)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(choices
|
||||
.into_iter()
|
||||
.filter_map(|llm| {
|
||||
let match_result = match_indices_case_insensitive(
|
||||
llm.display_name.to_lowercase().as_str(),
|
||||
query_text.as_str(),
|
||||
)?;
|
||||
|
||||
// Avoid spamming results with extremely weak matches.
|
||||
if query_text.len() > 1 && match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(QueryResult::from(
|
||||
ModelSearchItem::new(llm, &active_llm_id, app)
|
||||
.with_name_match_result(Some(match_result.clone()))
|
||||
.with_score(OrderedFloat(match_result.score as f64)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ModelSelectorDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ModelSearchItem {
|
||||
id: LLMId,
|
||||
provider: LLMProvider,
|
||||
spec: Option<LLMSpec>,
|
||||
provider_icon: Option<Icon>,
|
||||
display_text: String,
|
||||
is_selected: bool,
|
||||
disable_reason: Option<DisableReason>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
score: OrderedFloat<f64>,
|
||||
manage_api_key_mouse_state: MouseStateHandle,
|
||||
reasoning_level: Option<String>,
|
||||
discount_percentage: Option<f32>,
|
||||
}
|
||||
|
||||
impl ModelSearchItem {
|
||||
fn new(llm: &LLMInfo, active_llm_id: &LLMId, app: &AppContext) -> Self {
|
||||
// If the model requires an upgrade but the user already has a BYOK key
|
||||
// for this provider, treat it as enabled by clearing the disable reason.
|
||||
let disable_reason = if llm.disable_reason == Some(DisableReason::RequiresUpgrade)
|
||||
&& is_using_api_key_for_provider(&llm.provider, app)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
llm.disable_reason.clone()
|
||||
};
|
||||
Self {
|
||||
id: llm.id.clone(),
|
||||
provider: llm.provider.clone(),
|
||||
spec: llm.spec.clone(),
|
||||
provider_icon: llm.provider.icon(),
|
||||
display_text: llm.display_name.clone(),
|
||||
is_selected: &llm.id == active_llm_id,
|
||||
disable_reason,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(f64::MIN),
|
||||
manage_api_key_mouse_state: Default::default(),
|
||||
reasoning_level: llm.reasoning_level(),
|
||||
discount_percentage: llm.discount_percentage,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for ModelSearchItem {
|
||||
type Action = AcceptModel;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
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_warpui_icon(icon_color)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size)
|
||||
.with_height(icon_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use warpui::elements::{Flex, ParentElement as _};
|
||||
use warpui::prelude::CrossAxisAlignment;
|
||||
|
||||
let appearance = crate::appearance::Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
let primary_text_color = inline_styles::primary_text_color(theme, background_color.into());
|
||||
let secondary_text_color =
|
||||
inline_styles::secondary_text_color(theme, background_color.into());
|
||||
|
||||
let name_text_color = if self.is_disabled() {
|
||||
secondary_text_color
|
||||
} else {
|
||||
primary_text_color
|
||||
};
|
||||
|
||||
let mut text = Text::new_inline(
|
||||
self.display_text.clone(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(name_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
text = text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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_warpui_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());
|
||||
}
|
||||
|
||||
if self.is_selected {
|
||||
let selected_label = "(selected)";
|
||||
let selected_text = Text::new_inline(
|
||||
selected_label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
}),
|
||||
(0..selected_label.len()).collect(),
|
||||
)
|
||||
.finish();
|
||||
row = row.with_child(Container::new(selected_text).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
if self.is_disabled() {
|
||||
let disabled_label = "(disabled)";
|
||||
let disabled_text = Text::new_inline(
|
||||
disabled_label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties {
|
||||
style: Style::Italic,
|
||||
..Default::default()
|
||||
}),
|
||||
(0..disabled_label.len()).collect(),
|
||||
)
|
||||
.finish();
|
||||
row = row.with_child(Container::new(disabled_text).with_margin_left(6.).finish());
|
||||
}
|
||||
|
||||
if should_show_discount_chip(
|
||||
self.discount_percentage,
|
||||
is_using_api_key_for_provider(&self.provider, app),
|
||||
) {
|
||||
let discount_percentage = self.discount_percentage.unwrap_or(0.);
|
||||
let chip = Container::new(
|
||||
Text::new_inline(
|
||||
format!("{}% off!", discount_percentage.round() as u32),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(theme.ansi_fg_green())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(4.)
|
||||
.with_padding_right(4.)
|
||||
.with_background(theme.green_overlay_1())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_left(6.)
|
||||
.finish();
|
||||
row = row.with_child(chip);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &crate::appearance::Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn render_details(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
use warpui::elements::{Flex, ParentElement as _};
|
||||
|
||||
let appearance = crate::appearance::Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let (title, description) = if self.reasoning_level.is_some() {
|
||||
(REASONING_LEVEL_TITLE, REASONING_LEVEL_DESCRIPTION)
|
||||
} else {
|
||||
(MODEL_SPECS_TITLE, MODEL_SPECS_DESCRIPTION)
|
||||
};
|
||||
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 manage_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.manage_api_key_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Manage".to_string())
|
||||
.with_style(UiComponentStyles {
|
||||
height: Some(24.),
|
||||
padding: Some(Coords {
|
||||
top: 2.,
|
||||
bottom: 2.,
|
||||
left: 4.,
|
||||
right: 4.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_cursor(Some(Cursor::PointingHand))
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
CostRow::BilledToApi {
|
||||
manage_button: Container::new(manage_button).finish(),
|
||||
}
|
||||
} else {
|
||||
CostRow::Bar {
|
||||
value: self.spec.as_ref().map(|spec| spec.cost),
|
||||
}
|
||||
};
|
||||
|
||||
let scores = render_model_spec_scores(
|
||||
self.spec.as_ref(),
|
||||
cost_row,
|
||||
ModelSpecScoresLayout {
|
||||
bg_bar_color: internal_colors::neutral_3(theme),
|
||||
},
|
||||
app,
|
||||
);
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_child(Container::new(header).with_margin_bottom(12.).finish())
|
||||
.with_child(scores);
|
||||
|
||||
if self.disable_reason.as_ref() == Some(&DisableReason::RequiresUpgrade) {
|
||||
let upgrade_url = if let Some(team) = UserWorkspaces::as_ref(app).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.user_id()
|
||||
.unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
};
|
||||
|
||||
let mut display_name = self.display_text.clone();
|
||||
if let Some(first) = display_name.get_mut(..1) {
|
||||
first.make_ascii_uppercase();
|
||||
}
|
||||
|
||||
// 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()
|
||||
&& matches!(
|
||||
self.provider,
|
||||
LLMProvider::OpenAI | LLMProvider::Anthropic | LLMProvider::Google
|
||||
);
|
||||
|
||||
let mut text_fragments = vec![
|
||||
FormattedTextFragment::plain_text(format!(
|
||||
"{display_name} is not available for free users. "
|
||||
)),
|
||||
FormattedTextFragment::hyperlink("Upgrade", upgrade_url),
|
||||
];
|
||||
|
||||
if byok_available {
|
||||
text_fragments.push(FormattedTextFragment::plain_text(" or ".to_string()));
|
||||
text_fragments.push(FormattedTextFragment::hyperlink_action(
|
||||
"bring your own key",
|
||||
WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let upgrade_text = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(text_fragments)]),
|
||||
inline_styles::font_size(appearance),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
theme.disabled_ui_text_color().into_solid(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(|hyperlink_lens, event, ctx| {
|
||||
match hyperlink_lens {
|
||||
warpui::elements::HyperlinkLens::Url(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
warpui::elements::HyperlinkLens::Action(action_ref) => {
|
||||
if let Some(action) = action_ref.as_any().downcast_ref::<WorkspaceAction>()
|
||||
{
|
||||
event.dispatch_typed_action(action.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
column = column.with_child(Container::new(upgrade_text).with_margin_top(12.).finish());
|
||||
}
|
||||
|
||||
Some(
|
||||
ConstrainedBox::new(column.finish())
|
||||
.with_width(model_specs_width(app))
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn priority_tier(&self) -> u8 {
|
||||
if self.is_disabled() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptModel {
|
||||
id: self.id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn is_disabled(&self) -> bool {
|
||||
self.disable_reason.is_some()
|
||||
}
|
||||
|
||||
fn tooltip(&self) -> Option<String> {
|
||||
self.disable_reason
|
||||
.as_ref()
|
||||
.map(|reason| reason.tooltip_text().to_string())
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
let mut label = format!("Model: {}", self.display_text);
|
||||
if self.is_selected {
|
||||
label.push_str(" (selected)");
|
||||
}
|
||||
if self.is_disabled() {
|
||||
label.push_str(" (disabled)");
|
||||
}
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a promo discount chip should be shown for a model.
|
||||
/// Discounts only apply when the user is billing through Warp credits,
|
||||
/// so we suppress the chip when the user is routing through their own API key.
|
||||
fn should_show_discount_chip(discount_percentage: Option<f32>, is_using_byok: bool) -> bool {
|
||||
discount_percentage.is_some_and(|p| p > 0.) && !is_using_byok
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod data_source;
|
||||
mod model_spec_scores;
|
||||
mod view;
|
||||
|
||||
pub use data_source::{AcceptModel, ModelSelectorDataSource};
|
||||
pub use view::{InlineModelSelectorEvent, InlineModelSelectorTab, InlineModelSelectorView};
|
||||
@@ -0,0 +1,257 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warpui::elements::{
|
||||
Border, ConstrainedBox, Container, CornerRadius, Expanded, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement as _, Percentage, Radius, Rect, Stack, Text,
|
||||
};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity as _};
|
||||
|
||||
use crate::ai::llms::LLMSpec;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
|
||||
const CORNER_RADIUS: f32 = 4.0;
|
||||
const ROW_SPACING: f32 = 12.0;
|
||||
|
||||
pub const MODEL_SPECS_TITLE: &str = "Model Specs";
|
||||
pub const MODEL_SPECS_DESCRIPTION: &str = "Warp's benchmarks for how well a model performs in our harness, the rate at which it consumes credits, and task speed.";
|
||||
|
||||
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 enum CostRow {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
}
|
||||
|
||||
pub struct ModelSpecScoresLayout {
|
||||
pub bg_bar_color: ColorU,
|
||||
}
|
||||
|
||||
pub fn render_model_spec_scores(
|
||||
spec: Option<&LLMSpec>,
|
||||
cost_row: CostRow,
|
||||
layout: ModelSpecScoresLayout,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let mut rows = vec![render_score_row(
|
||||
"Intelligence",
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.quality),
|
||||
},
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
)];
|
||||
|
||||
rows.push(render_score_row(
|
||||
"Speed",
|
||||
ScoreRowKind::Bar {
|
||||
value: spec.as_ref().map(|spec| spec.speed),
|
||||
},
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
|
||||
match cost_row {
|
||||
CostRow::Bar { value } => {
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::Bar { value },
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
}
|
||||
CostRow::BilledToApi { manage_button } => {
|
||||
rows.push(render_score_row(
|
||||
"Cost",
|
||||
ScoreRowKind::BilledToApi { manage_button },
|
||||
layout.bg_bar_color,
|
||||
app,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(ROW_SPACING)
|
||||
.with_children(rows)
|
||||
.finish()
|
||||
}
|
||||
|
||||
enum ScoreRowKind {
|
||||
Bar { value: Option<f32> },
|
||||
BilledToApi { manage_button: Box<dyn Element> },
|
||||
}
|
||||
|
||||
fn render_score_row(
|
||||
name: &str,
|
||||
kind: ScoreRowKind,
|
||||
bg_bar_color: ColorU,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Approximate the required width for the longest label ("Intelligence"), and use this as a
|
||||
// consistent width for the labels so the labels and bars are vertically aligned.
|
||||
//
|
||||
// 8 ems is enough space for Intelligence with some right margin to spare.
|
||||
let label_width = app.font_cache().em_width(
|
||||
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 bar_height = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
);
|
||||
|
||||
let row_content: Box<dyn Element> = match kind {
|
||||
ScoreRowKind::Bar { value: Some(value) } => {
|
||||
let background_bar = Rect::new()
|
||||
.with_background_color(bg_bar_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
let filled_bar = Rect::new()
|
||||
.with_background_color(internal_colors::neutral_6(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Expanded::new(
|
||||
1.,
|
||||
ConstrainedBox::new(
|
||||
Stack::new()
|
||||
.with_child(background_bar)
|
||||
.with_child(Percentage::width(value, filled_bar).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(bar_height)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ScoreRowKind::Bar { value: None } => {
|
||||
let background_bar = Rect::new()
|
||||
.with_background_color(bg_bar_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish();
|
||||
|
||||
Expanded::new(
|
||||
1.,
|
||||
ConstrainedBox::new(
|
||||
Stack::new()
|
||||
.with_child(background_bar)
|
||||
.with_child(
|
||||
Align::new(
|
||||
Text::new(
|
||||
"?",
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(bg_bar_color.into())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(bar_height)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
ScoreRowKind::BilledToApi { 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(manage_button)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(label)
|
||||
.with_child(Expanded::new(1., row_content).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_model_spec_header(
|
||||
title: &str,
|
||||
description: &str,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let title = Text::new(
|
||||
title.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(),
|
||||
)
|
||||
.with_clip(ClipConfig::ellipsis())
|
||||
.finish();
|
||||
|
||||
let description = Text::new(
|
||||
description.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
inline_styles::font_size(appearance),
|
||||
)
|
||||
.with_color(theme.disabled_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(4.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(12.)
|
||||
.with_border(Border::bottom(1.).with_border_fill(theme.outline()))
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{ChildView, MainAxisSize};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::blocklist::block::cli_controller::{CLISubagentController, CLISubagentEvent};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::llms::{LLMId, LLMPreferences, LLMPreferencesEvent};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::data_source::{Query, QueryFilter};
|
||||
use crate::search::mixer::{SearchMixer, SearchMixerEvent};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
InlineMenuEvent, InlineMenuHeaderConfig, InlineMenuModel, InlineMenuPositioner,
|
||||
InlineMenuTabConfig, InlineMenuView,
|
||||
};
|
||||
use crate::terminal::input::models::data_source::{AcceptModel, ModelSelectorDataSource};
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
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;
|
||||
|
||||
impl ActionButtonTheme for ManageDefaultsTheme {
|
||||
fn background(&self, _hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
None
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
let theme = appearance.theme();
|
||||
if hovered {
|
||||
internal_colors::text_main(theme, theme.background())
|
||||
} else {
|
||||
internal_colors::text_sub(theme, theme.background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InlineModelSelectorTab {
|
||||
BaseAgent,
|
||||
FullTerminalUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlineModelSelectorEvent {
|
||||
SelectedModel {
|
||||
id: LLMId,
|
||||
selected_tab: InlineModelSelectorTab,
|
||||
set_as_default: bool,
|
||||
},
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
static TAB_CONFIGS: LazyLock<Vec<InlineMenuTabConfig<InlineModelSelectorTab>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut configs = vec![InlineMenuTabConfig {
|
||||
id: InlineModelSelectorTab::BaseAgent,
|
||||
label: "Base".to_string(),
|
||||
filters: HashSet::from([QueryFilter::BaseModels]),
|
||||
}];
|
||||
if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
configs.push(InlineMenuTabConfig {
|
||||
id: InlineModelSelectorTab::FullTerminalUse,
|
||||
label: "Full Terminal Use".to_string(),
|
||||
filters: HashSet::from([QueryFilter::FullTerminalUseModels]),
|
||||
});
|
||||
}
|
||||
configs
|
||||
});
|
||||
|
||||
struct TabSwitchSelection {
|
||||
model_id: Option<LLMId>,
|
||||
index: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct InlineModelSelectorView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptModel, InlineModelSelectorTab>>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptModel>>,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
input_buffer_model: ModelHandle<InputBufferModel>,
|
||||
terminal_view_id: EntityId,
|
||||
/// Stashed selection state to restore after a tab switch requery.
|
||||
selection_before_tab_switch: Option<TabSwitchSelection>,
|
||||
/// Controls whether or not we should filter the contents of the menu
|
||||
/// based on the contents of the input.
|
||||
filter_results_by_input: bool,
|
||||
}
|
||||
|
||||
impl InlineModelSelectorView {
|
||||
pub fn new(
|
||||
terminal_view_id: EntityId,
|
||||
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
cli_subagent_controller: ModelHandle<CLISubagentController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| ModelSelectorDataSource::new(terminal_view_id));
|
||||
|
||||
let tab_configs = TAB_CONFIGS.clone();
|
||||
let initial_filters = tab_configs
|
||||
.first()
|
||||
.map(|config| config.filters.clone())
|
||||
.unwrap_or_default();
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptModel>::new();
|
||||
mixer.add_sync_source(
|
||||
data_source.clone(),
|
||||
[QueryFilter::BaseModels, QueryFilter::FullTerminalUseModels],
|
||||
);
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: String::new(),
|
||||
filters: initial_filters,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = if FeatureFlag::InlineMenuHeaders.is_enabled() {
|
||||
let manage_defaults_button = ctx.add_view(|_| {
|
||||
ActionButton::new("Manage defaults", ManageDefaultsTheme)
|
||||
.with_icon(Icon::Settings)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: String::new(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
})
|
||||
});
|
||||
let header_config = InlineMenuHeaderConfig {
|
||||
label: "/model".to_string(),
|
||||
trailing_element: Some(Box::new(move |_app: &AppContext| {
|
||||
ChildView::new(&manage_defaults_button).finish()
|
||||
})),
|
||||
};
|
||||
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
let menu = InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.with_header_config(header_config);
|
||||
|
||||
let menu_model = menu.model().clone();
|
||||
let cli_ctrl = cli_subagent_controller.clone();
|
||||
menu.with_banner_fn(move |app| {
|
||||
let active_tab = menu_model
|
||||
.as_ref(app)
|
||||
.active_tab_id()
|
||||
.unwrap_or(InlineModelSelectorTab::BaseAgent);
|
||||
let history = BlocklistAIHistoryModel::as_ref(app);
|
||||
|
||||
let main_agent_in_progress = history
|
||||
.active_conversation(terminal_view_id)
|
||||
.is_some_and(|c| !c.is_empty() && c.status().is_in_progress());
|
||||
let is_cli_agent_in_control_or_tagged_in =
|
||||
cli_ctrl.as_ref(app).is_agent_in_control_or_tagged_in();
|
||||
let message = match active_tab {
|
||||
InlineModelSelectorTab::FullTerminalUse if main_agent_in_progress && !is_cli_agent_in_control_or_tagged_in => {
|
||||
Some("You're using the base agent. Full terminal use models only apply to the full terminal use agent.")
|
||||
}
|
||||
InlineModelSelectorTab::BaseAgent if is_cli_agent_in_control_or_tagged_in => {
|
||||
Some("You're using the full terminal use agent. Base models only apply to the base agent.")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
message.map(|msg| {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
Alert::new().render(
|
||||
AlertConfig::warning(msg.to_string())
|
||||
.with_main_axis_size(MainAxisSize::Max),
|
||||
appearance,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new_with_tabs(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&suggestions_mode_model,
|
||||
agent_view_controller,
|
||||
tab_configs,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem {
|
||||
item,
|
||||
cmd_or_ctrl_shift_enter,
|
||||
} => {
|
||||
ctx.emit(InlineModelSelectorEvent::SelectedModel {
|
||||
id: item.id.clone(),
|
||||
selected_tab: me.active_tab(ctx),
|
||||
set_as_default: *cmd_or_ctrl_shift_enter,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlineModelSelectorEvent::Dismissed);
|
||||
}
|
||||
InlineMenuEvent::TabChanged => {
|
||||
me.selection_before_tab_switch = Some(TabSwitchSelection {
|
||||
model_id: me
|
||||
.menu_model(ctx)
|
||||
.selected_item()
|
||||
.map(|item| item.id.clone()),
|
||||
index: me.menu_view.as_ref(ctx).selected_idx(),
|
||||
});
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. } | InlineMenuEvent::NoResults => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&suggestions_mode_model, |me, model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
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.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.reset_results(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, _, ctx| {
|
||||
if !me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !me.filter_results_by_input {
|
||||
// If the user clears the buffer, we should re-enable filtering so that
|
||||
// any fresh typing acts as a search query.
|
||||
if me.input_buffer_model.as_ref(ctx).current_value().is_empty() {
|
||||
me.filter_results_by_input = true;
|
||||
me.rerun_query(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
me.rerun_query(ctx);
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&LLMPreferences::handle(ctx),
|
||||
|me, _, event, ctx| match event {
|
||||
LLMPreferencesEvent::UpdatedAvailableLLMs
|
||||
| LLMPreferencesEvent::UpdatedActiveAgentModeLLM
|
||||
if me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector() =>
|
||||
{
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&ApiKeyManager::handle(ctx), |me, _, event, ctx| {
|
||||
if !matches!(event, ApiKeyManagerEvent::KeysUpdated) {
|
||||
return;
|
||||
}
|
||||
if me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
me.mixer.update(ctx, |mixer, ctx| {
|
||||
if let Some(query) = mixer.current_query().cloned() {
|
||||
mixer.run_query(query, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&cli_subagent_controller, |me, _, event, ctx| match event {
|
||||
CLISubagentEvent::SpawnedSubagent { .. }
|
||||
| CLISubagentEvent::FinishedSubagent { .. }
|
||||
| CLISubagentEvent::UpdatedControl { .. } => {
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
CLISubagentEvent::UpdatedLastSnapshot
|
||||
| CLISubagentEvent::ToggledHideResponses
|
||||
| CLISubagentEvent::ControlHandedBackAfterTransfer => {}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
move |me, _, event, ctx| {
|
||||
if let BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
terminal_view_id: event_terminal_view_id,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if *event_terminal_view_id == terminal_view_id {
|
||||
me.menu_view.update(ctx, |_, ctx| ctx.notify());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&mixer, |me, _, event, ctx| {
|
||||
let SearchMixerEvent::ResultsChanged = event;
|
||||
|
||||
if !me
|
||||
.suggestions_mode_model
|
||||
.as_ref(ctx)
|
||||
.is_inline_model_selector()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// On tab switch, try to preserve the previously selected model.
|
||||
// If the model isn't present in the new tab's results, fall back
|
||||
// to the same index position.
|
||||
if let Some(selection) = me.selection_before_tab_switch.take() {
|
||||
let found_by_id = selection.model_id.is_some_and(|id| {
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_first_where(|item| item.id == id, ctx)
|
||||
})
|
||||
});
|
||||
if !found_by_id {
|
||||
if let Some(idx) = selection.index {
|
||||
let count = me.menu_view.as_ref(ctx).result_count();
|
||||
if count > 0 {
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_idx(idx.min(count - 1), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user is actively filtering, don't override their selection.
|
||||
if me.filter_results_by_input
|
||||
&& !me.input_buffer_model.as_ref(ctx).current_value().is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let active_id = me.active_model_id_for_current_tab(ctx);
|
||||
|
||||
me.menu_view.update(ctx, |menu, ctx| {
|
||||
menu.select_first_where(|item| item.id == active_id, ctx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
mixer,
|
||||
suggestions_mode_model,
|
||||
input_buffer_model: input_buffer_model.clone(),
|
||||
terminal_view_id,
|
||||
selection_before_tab_switch: None,
|
||||
filter_results_by_input: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_model<'a>(
|
||||
&self,
|
||||
ctx: &'a ViewContext<Self>,
|
||||
) -> &'a InlineMenuModel<AcceptModel, InlineModelSelectorTab> {
|
||||
self.menu_view.as_ref(ctx).model().as_ref(ctx)
|
||||
}
|
||||
|
||||
fn active_tab(&self, ctx: &ViewContext<Self>) -> InlineModelSelectorTab {
|
||||
self.menu_model(ctx)
|
||||
.active_tab_id()
|
||||
.unwrap_or(InlineModelSelectorTab::BaseAgent)
|
||||
}
|
||||
|
||||
fn active_model_id_for_current_tab(&self, ctx: &ViewContext<Self>) -> LLMId {
|
||||
let llm_preferences = LLMPreferences::as_ref(ctx);
|
||||
match self.active_tab(ctx) {
|
||||
InlineModelSelectorTab::BaseAgent => llm_preferences
|
||||
.get_active_base_model(ctx, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone(),
|
||||
InlineModelSelectorTab::FullTerminalUse => llm_preferences
|
||||
.get_active_cli_agent_model(ctx, Some(self.terminal_view_id))
|
||||
.id
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rerun_query(&self, ctx: &mut ViewContext<Self>) {
|
||||
let filters = self.menu_model(ctx).active_tab_filters();
|
||||
let text = if self.filter_results_by_input {
|
||||
self.input_buffer_model
|
||||
.as_ref(ctx)
|
||||
.current_value()
|
||||
.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(Query { text, filters }, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn filter_results_by_input(&self) -> bool {
|
||||
self.filter_results_by_input
|
||||
}
|
||||
|
||||
pub fn set_filter_results_by_input(&mut self, filter: bool) {
|
||||
self.filter_results_by_input = filter;
|
||||
}
|
||||
|
||||
pub fn set_active_tab(&self, tab: InlineModelSelectorTab, ctx: &mut ViewContext<Self>) {
|
||||
let index = self
|
||||
.menu_view
|
||||
.as_ref(ctx)
|
||||
.model()
|
||||
.as_ref(ctx)
|
||||
.tab_configs()
|
||||
.iter()
|
||||
.position(|config| config.id == tab);
|
||||
if let Some(index) = index {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.set_active_tab(index, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next_tab(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_next_tab(ctx))
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, cmd_or_ctrl_enter: bool, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(cmd_or_ctrl_enter, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlineModelSelectorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineModelSelectorView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineModelSelectorView {
|
||||
type Event = InlineModelSelectorEvent;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Data source for the inline plan menu.
|
||||
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::plans::search_item::PlanSearchItem;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
|
||||
pub struct PlanMenuDataSource {
|
||||
conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
impl PlanMenuDataSource {
|
||||
pub fn new(conversation_id: AIConversationId) -> Self {
|
||||
Self { conversation_id }
|
||||
}
|
||||
|
||||
pub fn set_conversation_id(&mut self, id: AIConversationId) {
|
||||
self.conversation_id = id;
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for PlanMenuDataSource {
|
||||
type Action = AcceptPlan;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let doc_model = AIDocumentModel::as_ref(app);
|
||||
let docs = doc_model.get_all_documents_for_conversation(self.conversation_id);
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
|
||||
if query_text.is_empty() {
|
||||
// Documents are already sorted oldest-first (ascending created_at).
|
||||
// InlineMenuView renders items bottom-to-top (last item = bottom = most recent),
|
||||
// so this ordering places the most recent plan at the bottom as desired.
|
||||
return Ok(docs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (id, doc))| QueryResult::from(PlanSearchItem::new(id, doc, idx)))
|
||||
.collect());
|
||||
}
|
||||
|
||||
Ok(docs
|
||||
.into_iter()
|
||||
.filter_map(|(id, doc)| {
|
||||
let match_result = match_indices_case_insensitive(&doc.title, &query_text)?;
|
||||
// Avoid spamming results with extremely weak matches. 10 is arbitrary
|
||||
if match_result.score < 10 {
|
||||
return None;
|
||||
}
|
||||
let score = OrderedFloat(match_result.score as f64);
|
||||
Some(QueryResult::from(
|
||||
PlanSearchItem::new(id, doc, 0)
|
||||
.with_name_match_result(Some(match_result))
|
||||
.with_score(score),
|
||||
))
|
||||
})
|
||||
.sorted_by(|a, b| b.score().cmp(&a.score()))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PlanMenuDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Inline plan menu for selecting among multiple AI document plans.
|
||||
mod data_source;
|
||||
mod search_item;
|
||||
mod view;
|
||||
|
||||
pub use view::{InlinePlanMenuEvent, InlinePlanMenuView};
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use warpui::keymap::Keystroke;
|
||||
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
use crate::terminal::input::inline_menu::{
|
||||
default_navigation_message_items, InlineMenuAction, InlineMenuMessageArgs, InlineMenuRowAction,
|
||||
InlineMenuType,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
/// Action emitted when a plan is selected from the inline plan menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcceptPlan {
|
||||
pub document_id: AIDocumentId,
|
||||
pub document_version: AIDocumentVersion,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for AcceptPlan {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::PlanMenu;
|
||||
|
||||
fn produce_inline_menu_message<T>(args: InlineMenuMessageArgs<'_, Self, T>) -> Option<Message> {
|
||||
let InlineMenuMessageArgs {
|
||||
inline_menu_model, ..
|
||||
} = args;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if let Some(item) = inline_menu_model.selected_item() {
|
||||
let accept_item = item.clone();
|
||||
items.push(MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(" open plan"),
|
||||
],
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(InlineMenuRowAction::Accept {
|
||||
item: accept_item.clone(),
|
||||
cmd_or_ctrl_enter: false,
|
||||
});
|
||||
},
|
||||
inline_menu_model.mouse_states().accept.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
items.extend(default_navigation_message_items(&args));
|
||||
Some(Message::new(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! SearchItem implementation for plan menu items.
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::{ConstrainedBox, Container, Highlight, ParentElement, Shrinkable, Text};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::prelude::{Align, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize};
|
||||
use warpui::text_layout::ClipConfig;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::document::ai_document_model::{AIDocument, AIDocumentVersion};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::search::{ItemHighlightState, SearchItem};
|
||||
use crate::terminal::input::inline_menu::styles as inline_styles;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
const ICON_SIZE: f32 = 14.;
|
||||
|
||||
/// Search item for rendering a plan in the inline plan menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct PlanSearchItem {
|
||||
document_id: AIDocumentId,
|
||||
title: String,
|
||||
version: AIDocumentVersion,
|
||||
created_at: chrono::DateTime<chrono::Local>,
|
||||
name_match_result: Option<FuzzyMatchResult>,
|
||||
/// Used as a stable ordering score (index in the list, or fuzzy match score).
|
||||
score: OrderedFloat<f64>,
|
||||
}
|
||||
|
||||
impl PlanSearchItem {
|
||||
pub fn new(document_id: AIDocumentId, doc: AIDocument, index: usize) -> Self {
|
||||
Self {
|
||||
document_id,
|
||||
title: doc.title,
|
||||
version: doc.version,
|
||||
created_at: doc.created_at,
|
||||
name_match_result: None,
|
||||
score: OrderedFloat(index as f64),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_name_match_result(mut self, result: Option<FuzzyMatchResult>) -> Self {
|
||||
self.name_match_result = result;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_score(mut self, score: OrderedFloat<f64>) -> Self {
|
||||
self.score = score;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchItem for PlanSearchItem {
|
||||
type Action = AcceptPlan;
|
||||
|
||||
fn render_icon(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background());
|
||||
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(Icon::Compass.to_warpui_icon(icon_color).finish())
|
||||
.with_width(ICON_SIZE)
|
||||
.with_height(ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(icon)
|
||||
.with_margin_right(inline_styles::ICON_MARGIN)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
_highlight_state: ItemHighlightState,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let font_size = inline_styles::font_size(appearance);
|
||||
let background_color = inline_styles::menu_background_color(app);
|
||||
|
||||
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 mut name_text =
|
||||
Text::new_inline(self.title.clone(), appearance.ui_font_family(), font_size)
|
||||
.with_color(primary_text_color.into())
|
||||
.with_clip(ClipConfig::ellipsis());
|
||||
|
||||
if let Some(name_match) = &self.name_match_result {
|
||||
if !name_match.matched_indices.is_empty() {
|
||||
name_text = name_text.with_single_highlight(
|
||||
Highlight::new().with_properties(Properties::default().weight(Weight::Bold)),
|
||||
name_match.matched_indices.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut primary_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., name_text.finish()).finish());
|
||||
|
||||
let timestamp_font_size = font_size * 12. / 14.;
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(self.created_at.to_utc()),
|
||||
appearance.ui_font_family(),
|
||||
timestamp_font_size,
|
||||
)
|
||||
.with_color(secondary_text_color.into())
|
||||
.finish();
|
||||
|
||||
let max_timestamp_width = app
|
||||
.font_cache()
|
||||
.em_width(appearance.ui_font_family(), timestamp_font_size)
|
||||
* 10.;
|
||||
primary_row.add_child(
|
||||
ConstrainedBox::new(Align::new(timestamp).right().finish())
|
||||
.with_width(max_timestamp_width)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
primary_row.finish()
|
||||
}
|
||||
|
||||
fn item_background(
|
||||
&self,
|
||||
highlight_state: ItemHighlightState,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Fill> {
|
||||
inline_styles::item_background(highlight_state, appearance)
|
||||
}
|
||||
|
||||
fn score(&self) -> OrderedFloat<f64> {
|
||||
self.score
|
||||
}
|
||||
|
||||
fn accept_result(&self) -> Self::Action {
|
||||
AcceptPlan {
|
||||
document_id: self.document_id,
|
||||
document_version: self.version,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_result(&self) -> Self::Action {
|
||||
self.accept_result()
|
||||
}
|
||||
|
||||
fn accessibility_label(&self) -> String {
|
||||
format!("Plan: {}", self.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Inline plan menu view for selecting among multiple AI document plans.
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use warpui::elements::ChildView;
|
||||
use warpui::{Element, Entity, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::AgentViewController;
|
||||
use crate::ai::document::ai_document_model::AIDocumentVersion;
|
||||
use crate::search::data_source::Query;
|
||||
use crate::search::mixer::SearchMixer;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::inline_menu::{InlineMenuEvent, InlineMenuPositioner, InlineMenuView};
|
||||
use crate::terminal::input::plans::data_source::PlanMenuDataSource;
|
||||
use crate::terminal::input::plans::AcceptPlan;
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
|
||||
/// Events emitted by InlinePlanMenuView.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InlinePlanMenuEvent {
|
||||
/// User selected a plan to open.
|
||||
OpenPlan {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
/// User dismissed the menu.
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
pub struct InlinePlanMenuView {
|
||||
menu_view: ViewHandle<InlineMenuView<AcceptPlan>>,
|
||||
data_source: ModelHandle<PlanMenuDataSource>,
|
||||
mixer: ModelHandle<SearchMixer<AcceptPlan>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
}
|
||||
|
||||
impl InlinePlanMenuView {
|
||||
pub fn new(
|
||||
conversation_id: AIConversationId,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
positioner: &ModelHandle<InlineMenuPositioner>,
|
||||
input_buffer_model: &ModelHandle<InputBufferModel>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let data_source = ctx.add_model(|_| PlanMenuDataSource::new(conversation_id));
|
||||
|
||||
let mixer = ctx.add_model(|ctx| {
|
||||
let mut mixer = SearchMixer::<AcceptPlan>::new();
|
||||
mixer.add_sync_source(data_source.clone(), []);
|
||||
mixer.run_query(Query::default(), ctx);
|
||||
mixer
|
||||
});
|
||||
|
||||
let menu_view = ctx.add_typed_action_view(|ctx| {
|
||||
InlineMenuView::new(
|
||||
mixer.clone(),
|
||||
positioner.clone(),
|
||||
&input_suggestions_model,
|
||||
agent_view_controller,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&menu_view, |_, _, event, ctx| match event {
|
||||
InlineMenuEvent::AcceptedItem { item, .. } => {
|
||||
ctx.emit(InlinePlanMenuEvent::OpenPlan {
|
||||
document_id: item.document_id,
|
||||
document_version: item.document_version,
|
||||
});
|
||||
}
|
||||
InlineMenuEvent::SelectedItem { .. }
|
||||
| InlineMenuEvent::NoResults
|
||||
| InlineMenuEvent::TabChanged => (),
|
||||
InlineMenuEvent::Dismissed => {
|
||||
ctx.emit(InlinePlanMenuEvent::Dismissed);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&input_suggestions_model,
|
||||
|me, input_suggestions_model, event, ctx| {
|
||||
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
|
||||
if let Some(conversation_id) = input_suggestions_model
|
||||
.as_ref(ctx)
|
||||
.plan_menu_conversation_id()
|
||||
{
|
||||
me.data_source.update(ctx, |ds, _| {
|
||||
ds.set_conversation_id(conversation_id);
|
||||
});
|
||||
me.refresh_results("", ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(input_buffer_model, |me, _, event, ctx| {
|
||||
if me.input_suggestions_model.as_ref(ctx).is_plan_menu() {
|
||||
let InputBufferUpdateEvent { new_content, .. } = event;
|
||||
me.refresh_results(new_content, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
menu_view,
|
||||
data_source,
|
||||
mixer,
|
||||
input_suggestions_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&self, search_query: &str, ctx: &mut ViewContext<Self>) {
|
||||
self.mixer.update(ctx, |mixer, ctx| {
|
||||
mixer.run_query(
|
||||
Query {
|
||||
text: search_query.to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_up(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_up(ctx));
|
||||
}
|
||||
|
||||
pub fn select_down(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view.update(ctx, |v, ctx| v.select_down(ctx));
|
||||
}
|
||||
|
||||
pub fn accept_selected_item(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.menu_view
|
||||
.update(ctx, |v, ctx| v.accept_selected_item(false, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
impl View for InlinePlanMenuView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlinePlanMenuView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.menu_view).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlinePlanMenuView {
|
||||
type Event = InlinePlanMenuEvent;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::{AppContext, Entity, EntityId, SingletonEntity};
|
||||
|
||||
use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId};
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::search::data_source::{Query, QueryResult};
|
||||
use crate::search::mixer::DataSourceRunErrorWrapper;
|
||||
use crate::search::SyncDataSource;
|
||||
use crate::terminal::input::inline_menu::{InlineMenuAction, InlineMenuType};
|
||||
use crate::terminal::input::profiles::search_item::ProfileSearchItem;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SelectProfileMenuItem {
|
||||
Profile { profile_id: ClientProfileId },
|
||||
ManageProfiles,
|
||||
}
|
||||
|
||||
impl InlineMenuAction for SelectProfileMenuItem {
|
||||
const MENU_TYPE: InlineMenuType = InlineMenuType::ProfileSelector;
|
||||
}
|
||||
|
||||
pub struct ProfileSelectorDataSource {
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
impl ProfileSelectorDataSource {
|
||||
pub fn new(terminal_view_id: EntityId) -> Self {
|
||||
Self { terminal_view_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncDataSource for ProfileSelectorDataSource {
|
||||
type Action = SelectProfileMenuItem;
|
||||
|
||||
fn run_query(
|
||||
&self,
|
||||
query: &Query,
|
||||
app: &AppContext,
|
||||
) -> Result<Vec<QueryResult<Self::Action>>, DataSourceRunErrorWrapper> {
|
||||
let profiles_model = AIExecutionProfilesModel::as_ref(app);
|
||||
let active_profile_id = *profiles_model
|
||||
.active_profile(Some(self.terminal_view_id), app)
|
||||
.id();
|
||||
let query_text = query.text.trim().to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
if query_text.is_empty() {
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_manage_profiles_item(),
|
||||
));
|
||||
} else if let Some(match_result) =
|
||||
match_indices_case_insensitive("manage profiles", &query_text)
|
||||
{
|
||||
let score = match_result.score;
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_manage_profiles_item()
|
||||
.with_match_result(match_result)
|
||||
.with_score(OrderedFloat(score as f64)),
|
||||
));
|
||||
}
|
||||
|
||||
let mut profiles: Vec<(ClientProfileId, String)> = profiles_model
|
||||
.get_all_profile_ids()
|
||||
.into_iter()
|
||||
.filter_map(|profile_id| {
|
||||
let profile_info = profiles_model.get_profile_by_id(profile_id, app)?;
|
||||
let profile_name = profile_info.data().display_name();
|
||||
Some((profile_id, profile_name))
|
||||
})
|
||||
.collect();
|
||||
profiles.sort_by(|(_, a), (_, b)| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||
|
||||
for (profile_id, profile_name) in profiles {
|
||||
if query_text.is_empty() {
|
||||
results.push(QueryResult::from(ProfileSearchItem::new_profile_item(
|
||||
profile_id,
|
||||
profile_name,
|
||||
profile_id == active_profile_id,
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(match_result) =
|
||||
match_indices_case_insensitive(&profile_name.to_lowercase(), &query_text)
|
||||
{
|
||||
let score = match_result.score;
|
||||
results.push(QueryResult::from(
|
||||
ProfileSearchItem::new_profile_item(
|
||||
profile_id,
|
||||
profile_name,
|
||||
profile_id == active_profile_id,
|
||||
)
|
||||
.with_match_result(match_result)
|
||||
.with_score(OrderedFloat(score as f64)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ProfileSelectorDataSource {
|
||||
type Event = ();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user