Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+51
View File
@@ -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;
+77
View File
@@ -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
+35
View File
@@ -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
}
+33
View File
@@ -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.",
},
]);
+32
View File
@@ -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(())
}
}
+21
View File
@@ -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(())
}
}
+38
View File
@@ -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 {}
+12
View File
@@ -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(())
}
}
+15
View File
@@ -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) }?)
}
}
+994
View File
@@ -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;
+265
View File
@@ -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));
}
+761
View File
@@ -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
+34
View File
@@ -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
+121
View File
@@ -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
}
}
+231
View File
@@ -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)
}
+327
View File
@@ -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;
+64
View File
@@ -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")
}
+898
View File
@@ -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();
}
}
}
}
+517
View File
@@ -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());
}
}
+534
View File
@@ -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());
}
+533
View File
@@ -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,
);
});
});
}
+622
View File
@@ -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()
}
}
+508
View File
@@ -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:?})")
}
}
}
}
+84
View File
@@ -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()
}
}
+7
View File
@@ -0,0 +1,7 @@
#[allow(dead_code)]
pub mod model;
pub use model::{
BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions, FindableRichContentView,
RichContentMatchId, TerminalFindModel,
};
+332
View File
@@ -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()
}
}
+174
View File
@@ -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));
}
+667
View File
@@ -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));
}
}
+39
View File
@@ -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
}
})
})
}
}
+175
View File
@@ -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
})
}
}
+216
View File
@@ -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])
}
+263
View File
@@ -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
}
+102
View File
@@ -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()
})
}
+971
View File
@@ -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;
+132
View File
@@ -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
+610
View File
@@ -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)
}
}
+63
View File
@@ -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;
}
+413
View File
@@ -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()
}
}
+196
View File
@@ -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()
}
}
+543
View File
@@ -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 = ();
}
+101
View File
@@ -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;
}
+389
View File
@@ -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;
+101
View File
@@ -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()
},
)
}
}
+98
View File
@@ -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,
}
}
}
+153
View File
@@ -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.;
}
+362
View File
@@ -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
}
+6
View File
@@ -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()
}
+498
View File
@@ -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 = ();
}
+58
View File
@@ -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))
}
}
+165
View File
@@ -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)
}
}
+151
View File
@@ -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