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
+256
View File
@@ -0,0 +1,256 @@
//! Context chips built into Warp
use chrono::Local;
use warp_util::path::user_friendly_path;
use crate::terminal::shell::ShellType;
use super::{
context_chip::{GeneratorContext, ShellCommand, ShellCommandGenerator},
ChipValue,
};
#[cfg(test)]
#[path = "builtins_tests.rs"]
mod tests;
/// Generator function for the current working directory.
pub fn working_directory(ctx: &GeneratorContext) -> Option<ChipValue> {
let pwd = ctx.active_block_metadata.current_working_directory()?;
let home_dir = ctx.active_session.and_then(|session| session.home_dir());
Some(ChipValue::Text(
user_friendly_path(pwd, home_dir).to_string(),
))
}
/// Generator function that always shows the username.
pub fn username(ctx: &GeneratorContext) -> Option<ChipValue> {
ctx.active_session
.map(|session| ChipValue::Text(session.user().to_owned()))
}
/// Generator function that always shows the host name.
pub fn hostname(ctx: &GeneratorContext) -> Option<ChipValue> {
ctx.active_session
.map(|session| ChipValue::Text(session.hostname().to_owned()))
}
/// Generator function that shows the current Python virtual environment.
pub fn virtual_environment(ctx: &GeneratorContext) -> Option<ChipValue> {
ctx.current_environment
.python_virtualenv()
.cloned()
.map(ChipValue::Text)
}
/// Generator function that shows the current Anaconda/conda environment.
pub fn conda_environment(ctx: &GeneratorContext) -> Option<ChipValue> {
ctx.current_environment
.conda_environment()
.cloned()
.map(ChipValue::Text)
}
/// Generator function that shows the current Node.js version.
pub fn node_version(ctx: &GeneratorContext) -> Option<ChipValue> {
ctx.current_environment
.node_version()
.cloned()
.map(ChipValue::Text)
}
/// Generator function that shows the current date.
pub fn date(_: &GeneratorContext) -> Option<ChipValue> {
Some(ChipValue::Text(
Local::now().format("%a %b %d %Y").to_string(),
))
}
/// Generator function that shows the current time in 12-hour format.
pub fn time12(_: &GeneratorContext) -> Option<ChipValue> {
Some(ChipValue::Text(Local::now().format("%I:%M %P").to_string()))
}
/// Generator function that shows the current time in 24-hour format.
pub fn time24(_: &GeneratorContext) -> Option<ChipValue> {
Some(ChipValue::Text(Local::now().format("%H:%M").to_string()))
}
/// Generator function that shows the current 12-hour time with seconds.
pub fn time12_with_seconds(_: &GeneratorContext) -> Option<ChipValue> {
Some(ChipValue::Text(
Local::now().format("%I:%M:%S %P").to_string(),
))
}
/// Generator function that shows the current 24-hour time with seconds.
pub fn time24_with_seconds(_: &GeneratorContext) -> Option<ChipValue> {
Some(ChipValue::Text(Local::now().format("%H:%M:%S").to_string()))
}
/// Generator function for SSH session chip.
pub fn ssh_session(ctx: &GeneratorContext) -> Option<ChipValue> {
let session = ctx.active_session?;
if session.is_legacy_ssh_session()
|| matches!(
session.session_type(),
crate::terminal::model::session::SessionType::WarpifiedRemote { .. }
)
{
let user = session.user();
Some(ChipValue::Text(format!("{}@{}", user, session.hostname())))
} else {
None
}
}
/// Generator function for Subshell session chip.
pub fn subshell(ctx: &GeneratorContext) -> Option<ChipValue> {
let session = ctx.active_session?;
let subshell_info = session.subshell_info().as_ref()?;
let session_type = if let Some(env_var_collection_name) = &subshell_info.env_var_collection_name
{
env_var_collection_name.clone()
} else {
subshell_info
.spawning_command
.split_whitespace()
.next()
.unwrap_or("subshell")
.to_string()
};
Some(ChipValue::Text(session_type))
}
/// Generator function that shows the current Git branch.
pub fn shell_git_branch() -> ShellCommandGenerator {
// Note this command must stay in sync with how PrecmdValue::git_branch is generated in the
// bootstrap scripts, at least until that is removed.
const SH_COMMAND: &str = "GIT_OPTIONAL_LOCKS=0 git symbolic-ref --short HEAD 2> /dev/null || \
GIT_OPTIONAL_LOCKS=0 git rev-parse --short HEAD 2> /dev/null";
let pwsh_command = safe_git_powershell(
"git symbolic-ref --short HEAD 2>$null; \
if ($? -eq $false) { \
git rev-parse --short HEAD 2>$null; \
}",
);
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, pwsh_command),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, SH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
}
pub fn shell_other_git_branches() -> ShellCommandGenerator {
const SH_COMMAND: &str = "git --no-optional-locks branch --no-color --sort=-committerdate";
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, SH_COMMAND.to_string()),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, SH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
}
/// Generator function to get summary of git diff (num files changed and num lines changed).
///
/// Used as a remote-session fallback when GitRepoStatusModel is unavailable.
pub fn shell_git_line_changes() -> ShellCommandGenerator {
const GIT_COMMAND: &str =
"GIT_OPTIONAL_LOCKS=0 git -c diff.autoRefreshIndex=false diff --shortstat HEAD";
let command = ShellCommand::shell_specific([
(ShellType::Bash, GIT_COMMAND.to_string()),
(ShellType::Zsh, GIT_COMMAND.to_string()),
(ShellType::Fish, GIT_COMMAND.to_string()),
(
ShellType::PowerShell,
safe_git_powershell("git -c diff.autoRefreshIndex=false diff --shortstat HEAD"),
),
]);
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
}
pub fn github_pull_request_url() -> ShellCommandGenerator {
// `gh pr view` exits non-zero both when there is no PR for the current branch and when the
// command actually fails. We inspect its output so that "no PR found" is treated as an empty
// success, while auth/config/network failures still propagate as real failures.
const SH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.sh");
const FISH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.fish");
const PWSH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.ps1");
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, PWSH_COMMAND.to_string()),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, FISH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["gh".to_owned(), "git".to_owned()]))
}
pub fn kubernetes_current_context() -> ShellCommandGenerator {
ShellCommandGenerator::new(
ShellCommand::portable("kubectl config current-context"),
Some(vec!["kubectl".to_owned()]),
)
}
/// Generator function that shows the current svn "branch".
/// Since svn uses directories for different branches and tags,
/// we take the latest directory of the working copy as the branch/tag name.
pub fn svn_branch_context() -> ShellCommandGenerator {
const SH_COMMAND: &str = "basename $(svn info --show-item wc-root)";
const PWSH_COMMAND: &str = "svn info --show-item wc-root | Split-Path -Leaf";
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, PWSH_COMMAND.to_string()),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, SH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["svn".to_owned()]))
}
/// Generator function that shows the number of uncommitted svn files/directories.
pub fn svn_dirty_items() -> ShellCommandGenerator {
const SH_COMMAND: &str = "count=$(svn status | wc -l) \
&& (( $count > 0 )) && echo $(( $count ))";
const FISH_COMMAND: &str = "set count (svn status | wc -l) \
&& test $count -gt 0 && string trim $count";
const PWSH_COMMAND: &str = "svn status | Measure-Object -line | \
where {$_.Lines -gt 0 } | foreach { $_.Lines }";
let command = ShellCommand::shell_specific([
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, FISH_COMMAND.to_string()),
(ShellType::PowerShell, PWSH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["svn".to_owned()]))
}
fn safe_git_powershell(cmd: &str) -> String {
format!(
"\
$gitOptionalLocks = $env:GIT_OPTIONAL_LOCKS; \
$env:GIT_OPTIONAL_LOCKS = 0; \
try {{ \
{cmd} \
}} finally {{ \
$success = $?; \
$exitCode = $LASTEXITCODE; \
$env:GIT_OPTIONAL_LOCKS = $gitOptionalLocks; \
if ($exitCode -ne 0 -or -not $success) {{ \
throw \
}} \
}}"
)
}
+154
View File
@@ -0,0 +1,154 @@
use std::sync::Arc;
use crate::{
context_chips::context_chip::GeneratorContext,
terminal::model::{
block::BlockMetadata,
session::{
command_executor::testing::TestCommandExecutor, BootstrapSessionType, Session,
SessionInfo,
},
},
terminal::shell::ShellType,
};
#[test]
fn test_working_directory() {
let session = Session::test();
// SessionInfo forces the home directory in tests.
let home_dir = session.home_dir().expect("Home dir is set in tests");
let block_in_cwd = BlockMetadata::new(Some(session.id()), Some(format!("{home_dir}/projects")));
assert_eq!(
super::working_directory(&GeneratorContext {
active_block_metadata: &block_in_cwd,
active_session: Some(&session),
current_environment: &Default::default(),
})
.as_ref()
.and_then(|v| v.as_text()),
Some("~/projects")
);
let block_outside_cwd = BlockMetadata::new(Some(session.id()), Some("/etc".to_string()));
assert_eq!(
super::working_directory(&GeneratorContext {
active_block_metadata: &block_outside_cwd,
active_session: Some(&session),
current_environment: &Default::default(),
})
.as_ref()
.and_then(|v| v.as_text()),
Some("/etc")
);
}
#[test]
fn test_remote_sessions() {
let local_session = Session::test();
let remote_session = Session::new(
SessionInfo::new_for_test()
.with_session_type(BootstrapSessionType::WarpifiedRemote)
.with_hostname("remote-host".to_string())
.with_user("remote-user".to_string()),
Arc::new(TestCommandExecutor {}),
);
let local_ctx = GeneratorContext {
active_block_metadata: &BlockMetadata::new(Some(local_session.id()), None),
active_session: Some(&local_session),
current_environment: &Default::default(),
};
let remote_ctx = GeneratorContext {
active_block_metadata: &BlockMetadata::new(Some(remote_session.id()), None),
active_session: Some(&remote_session),
current_environment: &Default::default(),
};
// The Username and Hostname chips are always present.
assert_eq!(
super::username(&local_ctx)
.as_ref()
.and_then(|v| v.as_text()),
Some("local:user")
);
assert_eq!(
super::username(&remote_ctx)
.as_ref()
.and_then(|v| v.as_text()),
Some("remote-user")
);
assert_eq!(
super::hostname(&local_ctx)
.as_ref()
.and_then(|v| v.as_text()),
Some("local:host")
);
assert_eq!(
super::hostname(&remote_ctx)
.as_ref()
.and_then(|v| v.as_text()),
Some("remote-host")
);
// The SSH chip is only shown for remote sessions.
assert_eq!(super::ssh_session(&local_ctx), None);
assert_eq!(
super::ssh_session(&remote_ctx)
.as_ref()
.and_then(|v| v.as_text()),
Some("remote-user@remote-host")
);
}
#[test]
fn test_node_version() {
use crate::context_chips::context_chip::Environment;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::Session;
let session = Session::test();
let block_metadata = BlockMetadata::new(Some(session.id()), None);
// Test with no node version
let environment_no_node = Environment::default();
let ctx_no_node = GeneratorContext {
active_block_metadata: &block_metadata,
active_session: Some(&session),
current_environment: &environment_no_node,
};
assert_eq!(super::node_version(&ctx_no_node), None);
// Test with node version - create environment with node version
let environment_with_node = Environment::new(
None, // virtual_env
None, // conda_env
Some("v18.0.0".to_string()), // node_version
);
let ctx_with_node = GeneratorContext {
active_block_metadata: &block_metadata,
active_session: Some(&session),
current_environment: &environment_with_node,
};
assert_eq!(
super::node_version(&ctx_with_node)
.as_ref()
.and_then(|v| v.as_text()),
Some("v18.0.0")
);
}
#[test]
fn test_github_pull_request_url_command_avoids_zsh_status_assignment() {
let generator = super::github_pull_request_url();
let command = generator
.command()
.for_shell(ShellType::Zsh)
.expect("zsh command should exist");
assert!(command.contains("exit_code=$?"));
assert!(!command.contains("status=$?"));
assert!(!command.contains("status=$?;"));
}
+543
View File
@@ -0,0 +1,543 @@
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use super::ChipValue;
use crate::terminal::model::{
block::{Block, BlockMetadata},
session::{Session, SessionId},
};
use crate::terminal::shell::ShellType;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShellCommandGenerator {
command: ShellCommand,
dependencies: Vec<String>,
}
/// Representation of a shell command. The command may or may not be supported on all shells.
///
/// In YAML (as a hypothetical example), this should work with a variable format like:
/// ```yaml
/// # This parses to ShellCommand::Portable
/// - "this is a portable command"
/// # This parses to ShellCommand::ShellSpecific
/// - bash: "this works on bash"
/// zsh: "this works on zsh"
/// # this command does not support Fish
/// ```
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ShellCommand {
/// A shell command that works on all shells.
Portable(String),
/// A shell command that only works on specific shells.
ShellSpecific(HashMap<ShellType, String>),
}
impl ShellCommandGenerator {
pub fn command(&self) -> &ShellCommand {
&self.command
}
pub fn dependencies(&self) -> &[String] {
&self.dependencies
}
pub fn new(command: ShellCommand, dependencies: Option<Vec<String>>) -> Self {
Self {
command,
dependencies: dependencies.unwrap_or_default(),
}
}
}
impl ShellCommand {
/// Construct a new portable shell command.
pub fn portable(command: impl Into<String>) -> Self {
Self::Portable(command.into())
}
/// Construct a set of shell-specific commands.
pub fn shell_specific(commands: impl Into<HashMap<ShellType, String>>) -> Self {
Self::ShellSpecific(commands.into())
}
/// Gets the variant of this command that works on the given shell. If this command does not
/// support the shell, returns `None`.
pub fn for_shell(&self, shell_type: ShellType) -> Option<&str> {
match self {
Self::Portable(command) => Some(command.as_str()),
Self::ShellSpecific(commands) => commands.get(&shell_type).map(String::as_str),
}
}
}
/// Tracks whether the set of external commands (executables on `$PATH`) has been loaded for a
/// session, and if so, which required commands are present.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum ExternalCommandsAvailability {
#[default]
Unknown,
Known {
command_count: usize,
required_command_presence: HashMap<String, bool>,
},
}
impl ExternalCommandsAvailability {
pub fn contains(&self, command: &str) -> Option<bool> {
match self {
Self::Unknown => None,
Self::Known {
required_command_presence,
..
} => required_command_presence.get(command).copied(),
}
}
pub fn command_count(&self) -> Option<usize> {
match self {
Self::Unknown => None,
Self::Known { command_count, .. } => Some(*command_count),
}
}
}
/// A snapshot of session-level capabilities that a chip's runtime policy uses to determine
/// availability (e.g. whether the session is local, which executables are present).
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ChipRuntimeCapabilities {
pub session_id: Option<SessionId>,
pub session_is_local: Option<bool>,
pub external_commands: ExternalCommandsAvailability,
}
impl ChipRuntimeCapabilities {
pub fn from_session(session: &Session) -> Self {
Self::from_session_with_external_command_queries(session, std::iter::empty::<&str>(), false)
}
pub fn from_session_with_external_command_queries<'a>(
session: &Session,
required_executables: impl IntoIterator<Item = &'a str>,
include_external_command_count: bool,
) -> Self {
let external_commands = if session.has_loaded_external_commands() {
let mut required_command_presence = HashMap::new();
let required_executables = required_executables.into_iter().collect::<HashSet<_>>();
let should_scan_executables =
include_external_command_count || !required_executables.is_empty();
let mut command_count = 0;
if should_scan_executables {
for executable in session.executable_names() {
command_count += 1;
if required_executables.contains(executable) {
required_command_presence.insert(executable.to_string(), true);
}
}
}
for required_executable in required_executables {
required_command_presence
.entry(required_executable.to_string())
.or_insert(false);
}
ExternalCommandsAvailability::Known {
command_count,
required_command_presence,
}
} else {
ExternalCommandsAvailability::Unknown
};
Self {
session_id: Some(session.id()),
session_is_local: Some(session.is_local()),
external_commands,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChipDisabledReason {
RequiresLocalSession,
RequiresExecutable { command: String },
}
impl ChipDisabledReason {
pub fn tooltip_text(&self) -> String {
match self {
Self::RequiresLocalSession => "Requires a local session".to_string(),
Self::RequiresExecutable { command } if command == "gh" => {
"Requires the GitHub CLI".to_string()
}
Self::RequiresExecutable { command } => format!("Requires the `{command}` command"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum ChipAvailability {
#[default]
Enabled,
Disabled(ChipDisabledReason),
Hidden,
}
impl ChipAvailability {
pub fn is_enabled(&self) -> bool {
matches!(self, Self::Enabled)
}
pub fn tooltip_override_text(&self) -> Option<String> {
match self {
Self::Disabled(reason) => Some(reason.tooltip_text()),
Self::Enabled | Self::Hidden => None,
}
}
}
/// An input that contributes to a chip's fingerprint hash. When all fingerprint inputs match
/// the previously computed fingerprint, the chip can skip re-fetching its value.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ChipFingerprintInput {
SessionId,
SessionIsLocal,
WorkingDirectory,
GitBranch,
PythonVirtualenv,
CondaEnvironment,
NodeVersion,
SessionUser,
SessionHostname,
ExternalCommandsState,
RequiredExecutablesPresence,
/// A per-chip monotonic counter that increments each time a user command matching
/// the chip's `invalidate_on_commands` list completes, causing the fingerprint to change.
InvalidatingCommandCount,
}
/// Configuration that governs how a chip interacts with the runtime environment: which
/// executables it requires, whether it's restricted to local sessions, its shell command
/// timeout, and which inputs form its cache fingerprint.
#[derive(Clone, Debug, Default)]
pub struct ChipRuntimePolicy {
required_executables: Vec<String>,
local_only: bool,
shell_command_timeout: Option<Duration>,
fingerprint_inputs: Vec<ChipFingerprintInput>,
/// When true, if the chip's shell command fails (or times out), the chip records the current
/// fingerprint and skips re-execution on future fetches — including periodic refreshes —
/// until the fingerprint changes (e.g. branch or directory change).
suppress_on_failure: bool,
/// Top-level command names (e.g. `["git", "gh", "gt"]`) whose execution should
/// invalidate this chip's fingerprint. Pair with `ChipFingerprintInput::InvalidatingCommandCount`.
invalidate_on_commands: Vec<String>,
}
impl ChipRuntimePolicy {
pub fn new(
required_executables: impl IntoIterator<Item = impl Into<String>>,
local_only: bool,
shell_command_timeout: Option<Duration>,
fingerprint_inputs: impl IntoIterator<Item = ChipFingerprintInput>,
) -> Self {
Self {
required_executables: required_executables.into_iter().map(Into::into).collect(),
local_only,
shell_command_timeout,
fingerprint_inputs: fingerprint_inputs.into_iter().collect(),
suppress_on_failure: false,
invalidate_on_commands: Vec::new(),
}
}
pub fn for_shell_generator(generator: &ShellCommandGenerator) -> Self {
Self::new(
generator.dependencies().to_vec(),
false,
None,
std::iter::empty(),
)
}
pub fn required_executables(&self) -> &[String] {
&self.required_executables
}
pub fn shell_command_timeout(&self) -> Option<Duration> {
self.shell_command_timeout
}
pub fn fingerprint_inputs(&self) -> &[ChipFingerprintInput] {
&self.fingerprint_inputs
}
pub fn suppress_on_failure(&self) -> bool {
self.suppress_on_failure
}
pub fn with_suppress_on_failure(mut self) -> Self {
self.suppress_on_failure = true;
self
}
pub fn invalidate_on_commands(&self) -> &[String] {
&self.invalidate_on_commands
}
pub fn with_invalidate_on_commands(
mut self,
commands: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.invalidate_on_commands = commands.into_iter().map(Into::into).collect();
self
}
pub fn availability(&self, capabilities: &ChipRuntimeCapabilities) -> ChipAvailability {
if self.local_only && matches!(capabilities.session_is_local, Some(false)) {
return ChipAvailability::Disabled(ChipDisabledReason::RequiresLocalSession);
}
for command in &self.required_executables {
if matches!(
capabilities.external_commands.contains(command),
Some(false)
) {
return ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
command: command.clone(),
});
}
}
ChipAvailability::Enabled
}
}
/// Context for built-in contextual [`PromptGenerator`]s.
pub struct GeneratorContext<'a> {
/// The latest block in the session. While the prompt is shown, this block should have precmd
/// metadata but will not have executed yet.
pub active_block_metadata: &'a BlockMetadata,
/// The session that the active block is part of. This should always be available once the
/// session is bootstrapped. However, it may be missing due to errors restoring previous
/// sessions or extracting session info from the shell.
pub active_session: Option<&'a Session>,
/// The most-recently-available environment data for the terminal session. Unlike the username,
/// hostname, and other session-level info, this can change over the lifetime of a session -
/// users can activate/deactivate virtualenvs, change branches, and so on.
pub current_environment: &'a Environment,
}
/// Environment information for a terminal session.
#[derive(Debug, Clone, Default)]
pub struct Environment {
/// The Git branch that's checked out.
git_branch: Option<String>,
/// The name of the active Python virtual environment.
python_virtualenv: Option<String>,
/// The Anaconda environment name.
conda_environment: Option<String>,
/// The Node.js version.
node_version: Option<String>,
}
#[derive(Clone, Debug)]
pub enum PromptGenerator {
ShellCommand(ShellCommandGenerator),
Contextual {
/// A function that extracts the chip value from the prompt-generation context.
from_context_fn: fn(&GeneratorContext) -> Option<ChipValue>,
},
}
#[derive(Clone, Debug, Default)]
pub enum RefreshConfig {
#[default]
OnDemandOnly,
#[allow(dead_code)]
Periodically { interval: Duration },
#[allow(dead_code)]
OnFileChanges { filepath: String },
}
#[derive(Clone, Debug)]
pub struct ContextChip {
title: String,
generator: PromptGenerator,
on_click_generator: Option<PromptGenerator>,
/// TODO: this likely needs to move to a config state.
icon_path: Option<&'static str>,
refresh_config: RefreshConfig,
runtime_policy: ChipRuntimePolicy,
/// When `true`, a shell command that succeeds with empty output produces `Some("")` instead of `None`.
/// Useful for chips like `GitDiffStats` where empty output can still mean
/// a valid and clean working tree rather than "not applicable".
allow_empty_value: bool,
}
impl ContextChip {
/// Create a new built-in context chip using the given generator function.
pub fn builtin(
title: impl Into<String>,
generator: fn(&GeneratorContext) -> Option<ChipValue>,
refresh_config: RefreshConfig,
) -> Self {
Self::builtin_with_runtime_policy(
title,
generator,
refresh_config,
ChipRuntimePolicy::default(),
)
}
pub fn builtin_with_runtime_policy(
title: impl Into<String>,
generator: fn(&GeneratorContext) -> Option<ChipValue>,
refresh_config: RefreshConfig,
runtime_policy: ChipRuntimePolicy,
) -> Self {
Self {
title: title.into(),
generator: PromptGenerator::Contextual {
from_context_fn: generator,
},
on_click_generator: None,
icon_path: None,
refresh_config,
runtime_policy,
allow_empty_value: false,
}
}
pub fn shell_builtin(
title: impl Into<String>,
generator: ShellCommandGenerator,
on_click_generator: Option<ShellCommandGenerator>,
refresh_config: RefreshConfig,
) -> Self {
let runtime_policy = ChipRuntimePolicy::for_shell_generator(&generator);
Self::shell_builtin_with_runtime_policy(
title,
generator,
on_click_generator,
refresh_config,
runtime_policy,
)
}
pub fn shell_builtin_with_runtime_policy(
title: impl Into<String>,
generator: ShellCommandGenerator,
on_click_generator: Option<ShellCommandGenerator>,
refresh_config: RefreshConfig,
runtime_policy: ChipRuntimePolicy,
) -> Self {
Self {
title: title.into(),
generator: PromptGenerator::ShellCommand(generator),
on_click_generator: on_click_generator.map(PromptGenerator::ShellCommand),
icon_path: None,
refresh_config,
runtime_policy,
allow_empty_value: false,
}
}
pub fn new_custom_chip(title: String, shell_command_generator: ShellCommandGenerator) -> Self {
let runtime_policy = ChipRuntimePolicy::for_shell_generator(&shell_command_generator);
Self {
title,
generator: PromptGenerator::ShellCommand(shell_command_generator),
on_click_generator: None,
icon_path: None,
refresh_config: Default::default(),
runtime_policy,
allow_empty_value: false,
}
}
pub fn title(&self) -> &str {
self.title.as_str()
}
pub fn generator(&self) -> &PromptGenerator {
&self.generator
}
pub fn on_click_generator(&self) -> Option<&PromptGenerator> {
self.on_click_generator.as_ref()
}
pub fn refresh_config(&self) -> &RefreshConfig {
&self.refresh_config
}
pub fn runtime_policy(&self) -> &ChipRuntimePolicy {
&self.runtime_policy
}
pub fn availability(&self, capabilities: &ChipRuntimeCapabilities) -> ChipAvailability {
self.runtime_policy.availability(capabilities)
}
pub fn icon_path(&self) -> Option<&'static str> {
self.icon_path
}
pub fn allow_empty_value(&self) -> bool {
self.allow_empty_value
}
pub fn with_allow_empty_value(mut self) -> Self {
self.allow_empty_value = true;
self
}
}
impl Environment {
/// Create a new environment with the given values.
pub fn new(
python_virtualenv: Option<String>,
conda_environment: Option<String>,
node_version: Option<String>,
) -> Self {
Self {
git_branch: None,
python_virtualenv,
conda_environment,
node_version,
}
}
/// Snapshot the environment from a block.
pub fn from_block(block: &Block) -> Self {
Self {
git_branch: block.git_branch().cloned(),
python_virtualenv: block.virtual_env_short_name(),
conda_environment: block.conda_env().cloned(),
node_version: block.node_version().cloned(),
}
}
pub fn git_branch(&self) -> Option<&String> {
self.git_branch.as_ref()
}
pub fn python_virtualenv(&self) -> Option<&String> {
self.python_virtualenv.as_ref()
}
pub fn conda_environment(&self) -> Option<&String> {
self.conda_environment.as_ref()
}
pub fn node_version(&self) -> Option<&String> {
self.node_version.as_ref()
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
use std::cmp::Ordering;
use crate::completer::SessionContext;
use crate::ui_components::icons::Icon;
use typed_path::TypedPathBuf;
use warp_completer::completer::{EngineDirEntry, EngineFileType, PathCompletionContext};
use warp_util::file_type::is_binary_file;
use warpui::{r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext};
use super::display_menu::GenericMenuItem;
/// DirectoryFetcher model that caches directory state and provides an explicit refetch API
pub struct DirectoryFetcher {
current_directory: String,
/// Cached directory contents as menu items
cached_files: Vec<DirectoryItem>,
/// Session context for async operations (required for directory fetching)
session_context: Option<SessionContext>,
/// Handle to the fetch operation
fetch_handle: Option<SpawnedFutureHandle>,
}
#[derive(Debug, Clone)]
pub enum DirectoryFetcherEvent {
/// Emitted when directory contents have been updated
DirectoryContentsUpdated,
/// Emitted when a fetch operation starts
FetchStarted,
/// Emitted when a fetch operation completes (successfully or with error)
FetchCompleted { success: bool },
}
impl DirectoryFetcher {
/// Create a new DirectoryFetcher for the given directory
pub fn new(
directory_path: String,
session_context: Option<SessionContext>,
ctx: &mut ModelContext<Self>,
) -> Self {
let mut fetcher = Self {
current_directory: directory_path.clone(),
cached_files: vec![],
session_context,
fetch_handle: None,
};
fetcher.refetch_directory(ctx);
fetcher
}
/// Explicitly refetch the directory contents
pub fn refetch_directory(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_fetching() {
return;
}
// Always use async method - SessionContext works for both local and remote sessions
if let Some(session_ctx) = self.session_context.clone() {
let dir_path = self.current_directory.clone();
self.fetch_handle = Some(ctx.spawn(
async move { Self::fetch_files_async(&session_ctx, &dir_path).await },
|fetcher, files, ctx| {
fetcher.cached_files = files;
fetcher.fetch_handle = None;
ctx.emit(DirectoryFetcherEvent::DirectoryContentsUpdated);
ctx.emit(DirectoryFetcherEvent::FetchCompleted { success: true });
ctx.notify();
},
));
ctx.emit(DirectoryFetcherEvent::FetchStarted);
} else {
// If no session context, we can't fetch directory contents
log::warn!("No SessionContext available for directory fetching");
ctx.emit(DirectoryFetcherEvent::FetchCompleted { success: false });
ctx.notify();
}
}
/// Asynchronously list directory files using SessionContext
async fn fetch_files_async(
session_context: &SessionContext,
dir_path: &str,
) -> Vec<DirectoryItem> {
// Convert the directory path to TypedPathBuf, expanding ~ if needed
let expanded_path = shellexpand::tilde(dir_path).into_owned();
let typed_path = if expanded_path != dir_path {
TypedPathBuf::from(expanded_path)
} else {
TypedPathBuf::from(dir_path)
};
// Use SessionContext to get directory entries (works for both local and remote sessions)
let entries = session_context.list_directory_entries(typed_path).await;
// Convert EngineDirEntry to GenericMenuItem, filtering out hidden files
let mut items: Vec<DirectoryItem> = entries
.iter()
.filter(|entry| !entry.is_hidden()) // Skip hidden files (starting with '.')
.map(engine_entry_to_menu_item)
.collect();
// Sort: directories first, then text files, then other files, all alphabetically within their groups
sort_menu_items(&mut items);
items
}
/// Update the session context (useful when it becomes available later)
pub fn update_session_context(
&mut self,
session_context: Option<SessionContext>,
ctx: &mut ModelContext<Self>,
) {
if let Some(handle) = self.fetch_handle.take() {
// Cancel the fetch operation if it's in progress
handle.abort();
}
self.session_context = session_context;
self.refetch_directory(ctx);
}
/// Get the current directory path
pub fn current_directory(&self) -> &str {
&self.current_directory
}
/// Get the cached directory files
pub fn cached_files(&self) -> &[DirectoryItem] {
&self.cached_files
}
/// Check if a fetch operation is in progress
pub fn is_fetching(&self) -> bool {
self.fetch_handle.is_some()
}
/// Change the current directory and refetch contents
pub fn change_directory(&mut self, new_directory: String, ctx: &mut ModelContext<Self>) {
if self.current_directory != new_directory {
self.current_directory = new_directory;
self.cached_files.clear();
self.refetch_directory(ctx);
}
}
}
impl Entity for DirectoryFetcher {
type Event = DirectoryFetcherEvent;
}
#[derive(Debug, Clone, PartialOrd, PartialEq)]
pub enum DirectoryType {
Directory,
TextFile,
OtherFile,
NavigateToParent,
}
#[derive(Debug, Clone, PartialOrd, PartialEq)]
pub struct DirectoryItem {
pub name: String,
pub directory_type: DirectoryType,
}
impl GenericMenuItem for DirectoryItem {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
self.name.clone()
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
Some(match self.directory_type {
DirectoryType::Directory => Icon::Folder,
DirectoryType::TextFile => Icon::File,
DirectoryType::OtherFile => Icon::File,
DirectoryType::NavigateToParent => Icon::ArrowUp,
})
}
fn action_data(&self) -> String {
self.name.clone()
}
}
/// Sort menu items: directories first, then text files, then other files, all alphabetically within their groups
fn sort_menu_items(items: &mut [DirectoryItem]) {
items.sort_by(|a, b| {
match (&a.directory_type, &b.directory_type) {
(DirectoryType::Directory, DirectoryType::TextFile)
| (DirectoryType::Directory, DirectoryType::OtherFile) => Ordering::Less,
(DirectoryType::TextFile, DirectoryType::Directory)
| (DirectoryType::OtherFile, DirectoryType::Directory) => Ordering::Greater,
(DirectoryType::TextFile, DirectoryType::OtherFile) => Ordering::Less,
(DirectoryType::OtherFile, DirectoryType::TextFile) => Ordering::Greater,
_ => a.name.cmp(&b.name), // Same type, sort alphabetically
}
});
}
/// Convert an EngineDirEntry to a DirectoryItem
fn engine_entry_to_menu_item(entry: &EngineDirEntry) -> DirectoryItem {
let name: String = entry.file_name().to_string();
DirectoryItem {
name: name.clone(),
directory_type: match entry.file_type {
EngineFileType::Directory => DirectoryType::Directory,
EngineFileType::File => {
if is_binary_file(&name) {
DirectoryType::OtherFile
} else {
DirectoryType::TextFile
}
}
},
}
}
#[cfg(test)]
#[path = "directory_fetcher_tests.rs"]
mod tests;
@@ -0,0 +1,102 @@
use super::*;
fn create_directory_item(name: &str, directory_type: DirectoryType) -> DirectoryItem {
DirectoryItem {
name: name.to_string(),
directory_type,
}
}
#[test]
fn test_sort_comparison_total_order() {
// Test that sort_menu_items produces consistent ordering that prevents panics
// by verifying the expected Directory < TextFile < OtherFile hierarchy
// Test basic type ordering with different combinations
let mut items1 = vec![
create_directory_item("folder", DirectoryType::Directory),
create_directory_item("text.txt", DirectoryType::TextFile),
];
sort_menu_items(&mut items1);
assert_eq!(items1[0].directory_type, DirectoryType::Directory);
assert_eq!(items1[1].directory_type, DirectoryType::TextFile);
let mut items2 = vec![
create_directory_item("text.txt", DirectoryType::TextFile),
create_directory_item("binary.exe", DirectoryType::OtherFile),
];
sort_menu_items(&mut items2);
assert_eq!(items2[0].directory_type, DirectoryType::TextFile);
assert_eq!(items2[1].directory_type, DirectoryType::OtherFile);
let mut items3 = vec![
create_directory_item("folder", DirectoryType::Directory),
create_directory_item("binary.exe", DirectoryType::OtherFile),
];
sort_menu_items(&mut items3);
assert_eq!(items3[0].directory_type, DirectoryType::Directory);
assert_eq!(items3[1].directory_type, DirectoryType::OtherFile);
// Test that sort_menu_items is consistent - calling it multiple times
// on the same data should produce the same result
let test_items = vec![
create_directory_item("binary.exe", DirectoryType::OtherFile),
create_directory_item("folder", DirectoryType::Directory),
create_directory_item("text.txt", DirectoryType::TextFile),
];
let mut items_copy1 = test_items.clone();
let mut items_copy2 = test_items.clone();
sort_menu_items(&mut items_copy1);
sort_menu_items(&mut items_copy2);
// Both sorts should produce identical results
assert_eq!(items_copy1, items_copy2);
// Verify the expected ordering: Directory, TextFile, OtherFile
assert_eq!(items_copy1[0].directory_type, DirectoryType::Directory);
assert_eq!(items_copy1[1].directory_type, DirectoryType::TextFile);
assert_eq!(items_copy1[2].directory_type, DirectoryType::OtherFile);
}
#[test]
fn test_sort_same_types_alphabetically() {
let mut dirs = vec![
create_directory_item("zebra", DirectoryType::Directory),
create_directory_item("alpha", DirectoryType::Directory),
create_directory_item("beta", DirectoryType::Directory),
];
sort_menu_items(&mut dirs);
assert_eq!(dirs[0].name, "alpha");
assert_eq!(dirs[1].name, "beta");
assert_eq!(dirs[2].name, "zebra");
let mut texts = vec![
create_directory_item("z.txt", DirectoryType::TextFile),
create_directory_item("a.rs", DirectoryType::TextFile),
create_directory_item("m.py", DirectoryType::TextFile),
];
sort_menu_items(&mut texts);
assert_eq!(texts[0].name, "a.rs");
assert_eq!(texts[1].name, "m.py");
assert_eq!(texts[2].name, "z.txt");
let mut others = vec![
create_directory_item("z.bin", DirectoryType::OtherFile),
create_directory_item("a.exe", DirectoryType::OtherFile),
create_directory_item("m.dll", DirectoryType::OtherFile),
];
sort_menu_items(&mut others);
assert_eq!(others[0].name, "a.exe");
assert_eq!(others[1].name, "m.dll");
assert_eq!(others[2].name, "z.bin");
}
#[test]
fn test_sort_single_item() {
let mut items = vec![create_directory_item("single", DirectoryType::Directory)];
sort_menu_items(&mut items);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "single");
}
+447
View File
@@ -0,0 +1,447 @@
use std::sync::Arc;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::context_chips::display_chip::format_git_branch_command;
use crate::settings::InputSettings;
use crate::terminal::model_events::ModelEventDispatcher;
use crate::{
ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputEvent, BlocklistAIInputModel},
completer::SessionContext,
context_chips::display_chip::DisplayChipAction,
terminal::input::MenuPositioningProvider,
};
use std::path::PathBuf;
use warp_core::features::FeatureFlag;
use warpui::{
elements::{
ChildView, Clipped, Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Wrap,
},
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
View, ViewContext, ViewHandle,
};
use super::{
display_chip::{DisplayChip, DisplayChipConfig, PromptDisplayChipEvent},
git_line_changes_from_chips,
prompt_type::PromptType,
ChipResult, ContextChipKind,
};
/// Enum introduced to abstract over the different row types we use for the prompt display,
/// between the non-UDI and UDI cases.
enum RowBuilder {
Wrap(Wrap),
Flex(Flex),
}
impl RowBuilder {
fn add_child(&mut self, child: Box<dyn Element>) {
match self {
RowBuilder::Wrap(w) => w.add_child(child),
RowBuilder::Flex(f) => f.add_child(child),
}
}
fn finish(self) -> Box<dyn Element> {
match self {
RowBuilder::Wrap(w) => w.finish(),
RowBuilder::Flex(f) => f.finish(),
}
}
}
/// A view for displaying the prompt.
pub struct PromptDisplay {
prompt: ModelHandle<PromptType>,
display_chips: Vec<ViewHandle<DisplayChip>>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_context_model: ModelHandle<BlocklistAIContextModel>,
terminal_view_id: EntityId,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
session_context: Option<SessionContext>,
current_repo_path: Option<PathBuf>,
model_events: ModelHandle<ModelEventDispatcher>,
/// Whether the pane this prompt belongs to is currently focused.
pane_is_focused: bool,
/// Whether this terminal is viewing a shared session.
is_shared_session_viewer: bool,
agent_view_controller: ModelHandle<AgentViewController>,
}
const PROMPT_CHIP_DISPLAY_ID: &str = "PromptChipDisplay";
#[derive(Debug, Clone)]
pub enum PromptDisplayAction {
SelectGitBranch { value: String },
}
pub enum PromptDisplayEvent {
OpenFile(String),
OpenTextFileInCodeEditor(String),
ToggleMenu {
open: bool,
},
OpenCodeReview,
OpenConversationHistory,
OpenCommandPaletteFiles,
RunAgentQuery(String),
TryExecuteCommand(String),
OpenAIDocument {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
},
}
impl PromptDisplay {
#[allow(clippy::too_many_arguments)]
pub fn new(
prompt: ModelHandle<PromptType>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
ai_context_model: ModelHandle<BlocklistAIContextModel>,
terminal_view_id: EntityId,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
session_context: Option<SessionContext>,
current_repo_path: Option<PathBuf>,
model_events: ModelHandle<ModelEventDispatcher>,
agent_view_controller: ModelHandle<AgentViewController>,
is_shared_session_viewer: bool,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.observe(&prompt, |me, _, ctx| me.handle_prompt_change(ctx));
// Subscribe to AI input model changes to trigger re-render when input mode changes
ctx.subscribe_to_model(&ai_input_model, |_me, _model, event, ctx| {
match event {
BlocklistAIInputEvent::InputTypeChanged { .. }
| BlocklistAIInputEvent::LockChanged { .. } => {
// Trigger re-render to update chip visibility based on new input mode
ctx.notify();
}
}
});
// Subscribe todo list updates to refresh the todo list chip visibility
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if let BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id } = event {
if *terminal_view_id != me.terminal_view_id {
return;
}
ctx.notify();
}
},
);
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| {
ctx.notify();
});
Self {
prompt,
display_chips: vec![],
ai_input_model,
ai_context_model,
terminal_view_id,
menu_positioning_provider,
session_context,
current_repo_path,
model_events,
agent_view_controller,
pane_is_focused: true,
is_shared_session_viewer,
}
}
pub fn has_open_chip_menu(&self, app: &AppContext) -> bool {
self.display_chips
.iter()
.any(|chip| chip.as_ref(app).display_chip_kind().has_open_menu())
}
fn check_if_chip_values_have_changed(
&mut self,
new_chips: &[ChipResult],
ctx: &mut ViewContext<Self>,
) -> bool {
self.display_chips.len() != new_chips.len()
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
let existing_chip = &self.display_chips[i];
existing_chip.read(ctx, |chip, _| {
chip.text()
!= chip_result
.value
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default()
|| chip.chip_kind() != &chip_result.kind
// I'm only comparing the first on-click values for efficiency, but we may need to change this in the future.
|| chip.first_on_click_value() != chip_result.on_click_values.first()
})
})
}
fn handle_prompt_change(&mut self, ctx: &mut ViewContext<Self>) {
let new_chips = self.collect_chips(ctx);
let should_update = self.check_if_chip_values_have_changed(&new_chips, ctx);
if should_update {
self.reset_chips(&new_chips, ctx);
}
ctx.notify();
}
/// Collects the current chips from the prompt model, filtering out chips with no value.
fn collect_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
self.prompt
.as_ref(ctx)
.chips(ctx)
.into_iter()
.filter(|chip| chip.value.is_some())
.collect()
}
fn reset_chips(&mut self, new_chips: &[ChipResult], ctx: &mut ViewContext<Self>) {
let git_line_changes_info = git_line_changes_from_chips(new_chips);
self.display_chips.clear();
let mut display_chips = new_chips.iter().peekable();
while let Some(chip_result) = display_chips.next() {
let next_chip_kind = display_chips
.peek()
.map(|chip_result| chip_result.kind.clone());
let is_shared_session_viewer = self.is_shared_session_viewer;
let view_handle = ctx.add_typed_action_view(|ctx| {
let mut chip = DisplayChip::new(
ctx,
chip_result.clone(),
next_chip_kind,
DisplayChipConfig {
ai_input_model: self.ai_input_model.clone(),
ai_context_model: self.ai_context_model.clone(),
terminal_view_id: self.terminal_view_id,
menu_positioning_provider: self.menu_positioning_provider.clone(),
session_context: self.session_context.clone(),
current_repo_path: self.current_repo_path.clone(),
model_events: self.model_events.clone(),
is_shared_session_viewer,
agent_view_controller: self.agent_view_controller.clone(),
ambient_agent_view_model: None,
},
);
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
chip
});
ctx.subscribe_to_view(&view_handle, move |_, _, event, ctx| match event {
PromptDisplayChipEvent::OpenFile(value) => {
ctx.emit(PromptDisplayEvent::OpenFile(value.clone()));
ctx.notify();
}
PromptDisplayChipEvent::OpenTextFileInCodeEditor(value) => {
ctx.emit(PromptDisplayEvent::OpenTextFileInCodeEditor(value.clone()));
ctx.notify();
}
PromptDisplayChipEvent::ToggleMenu { open } => {
ctx.emit(PromptDisplayEvent::ToggleMenu { open: *open });
ctx.notify();
}
PromptDisplayChipEvent::OpenCodeReview => {
ctx.emit(PromptDisplayEvent::OpenCodeReview);
ctx.notify();
}
PromptDisplayChipEvent::OpenConversationHistory => {
ctx.emit(PromptDisplayEvent::OpenConversationHistory);
ctx.notify();
}
PromptDisplayChipEvent::OpenCommandPaletteFiles => {
ctx.emit(PromptDisplayEvent::OpenCommandPaletteFiles);
ctx.notify();
}
PromptDisplayChipEvent::RunAgentQuery(query) => {
ctx.emit(PromptDisplayEvent::RunAgentQuery(query.clone()));
ctx.notify();
}
PromptDisplayChipEvent::TryExecuteCommand(cmd) => {
ctx.emit(PromptDisplayEvent::TryExecuteCommand(cmd.clone()));
ctx.notify();
}
PromptDisplayChipEvent::OpenAIDocument {
document_id,
document_version,
} => {
ctx.emit(PromptDisplayEvent::OpenAIDocument {
document_id: *document_id,
document_version: *document_version,
});
ctx.notify();
}
});
self.display_chips.push(view_handle.clone());
}
}
pub fn on_pane_focus_changed(&mut self, focused: bool, ctx: &mut ViewContext<Self>) {
self.pane_is_focused = focused;
let new_chips = self.collect_chips(ctx);
self.reset_chips(&new_chips, ctx);
ctx.notify();
}
/// Update the session context and propagate it to all display chips
pub fn update_session_context(
&mut self,
session_context: Option<SessionContext>,
ctx: &mut ViewContext<Self>,
) {
self.session_context = session_context.clone();
// Update all existing display chips with the new session context
for chip_view in &self.display_chips {
chip_view.update(ctx, |chip, chip_ctx| {
chip.update_session_context(session_context.clone(), chip_ctx);
});
}
}
/// Update whether this terminal is viewing a shared session
pub fn update_shared_session_viewer_status(
&mut self,
is_viewer: bool,
ctx: &mut ViewContext<Self>,
) {
if self.is_shared_session_viewer != is_viewer {
self.is_shared_session_viewer = is_viewer;
// Re-render chips to show/hide the shared session viewer-specific chips
let new_chips = self.collect_chips(ctx);
self.reset_chips(&new_chips, ctx);
ctx.notify();
}
}
/// The current prompt text.
pub fn text(&self, ctx: &AppContext) -> String {
self.prompt.as_ref(ctx).prompt_as_string(ctx)
}
#[cfg(feature = "integration_tests")]
pub fn git_branch(&self, ctx: &AppContext) -> Option<String> {
self.prompt.read(ctx, |prompt, ctx| {
prompt
.chips(ctx)
.iter()
.find(|chip_result| matches!(chip_result.kind, ContextChipKind::ShellGitBranch))
.and_then(|chip_result| chip_result.value.as_ref().map(|v| v.to_string()))
})
}
pub fn close_all_chip_menus(&mut self, ctx: &mut ViewContext<Self>) {
for chip_view in &self.display_chips {
chip_view.update(ctx, |chip, chip_ctx| {
chip.handle_action(&DisplayChipAction::CloseMenu, chip_ctx);
});
}
ctx.notify();
}
/// Update the current repository path and rebuild chips.
pub fn update_repo_path(&mut self, repo_path: Option<PathBuf>, ctx: &mut ViewContext<Self>) {
self.current_repo_path = repo_path;
let new_chips = self.collect_chips(ctx);
self.reset_chips(&new_chips, ctx);
ctx.notify();
}
}
impl Entity for PromptDisplay {
type Event = PromptDisplayEvent;
}
impl TypedActionView for PromptDisplay {
type Action = PromptDisplayAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
PromptDisplayAction::SelectGitBranch { value } => {
ctx.emit(PromptDisplayEvent::TryExecuteCommand(
format_git_branch_command(value),
));
}
}
}
}
impl View for PromptDisplay {
fn ui_name() -> &'static str {
"PromptDisplay"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
// Try to focus any open menu in the display chips
for chip_view in &self.display_chips {
let menu_focused =
chip_view.update(ctx, |chip, chip_ctx| chip.try_focus_open_menu(chip_ctx));
if menu_focused {
return;
}
}
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let should_render_udi_chips = InputSettings::as_ref(app)
.is_universal_developer_input_enabled(app)
|| FeatureFlag::AgentView.is_enabled();
let mut row = if should_render_udi_chips {
RowBuilder::Wrap(
Wrap::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Min)
.with_run_spacing(super::spacing::UDI_ROW_RUN_SPACING),
)
} else {
RowBuilder::Flex(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_constrain_horizontal_bounds_to_parent(true)
.with_main_axis_size(MainAxisSize::Min),
)
};
self.display_chips.iter().for_each(|display_chip| {
let chip = display_chip.as_ref(app);
// AgentPlanAndTodoList is only shown in the agent input footer
if matches!(chip.chip_kind(), ContextChipKind::AgentPlanAndTodoList) {
return;
}
if chip.should_render(app) {
row.add_child(ChildView::new(display_chip).finish());
}
});
// This is a hack to apply horizontal clipping without vertical clipping (for padding).
Container::new(
Clipped::new(
Container::new(row.finish())
.with_vertical_margin(4.)
.finish(),
)
.finish(),
)
.with_vertical_margin(-4.)
.finish()
}
}
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
use super::{truncate_from_beginning, GitLineChanges};
use crate::context_chips::{github_pr_display_text_from_url, ContextChipKind};
#[test]
fn test_github_pr_display_text_from_url() {
assert_eq!(
github_pr_display_text_from_url("https://github.com/warp/warp/pull/123"),
Some("PR #123".to_string())
);
}
#[test]
fn test_github_pr_display_text_from_url_rejects_non_pr_urls() {
assert_eq!(
github_pr_display_text_from_url("https://github.com/warp/warp/issues/123"),
None
);
assert_eq!(
github_pr_display_text_from_url("https://github.com/warp/warp/pull/not-a-number"),
None
);
}
#[test]
fn test_github_pr_chip_display_value_formats_url() {
let value =
crate::context_chips::ChipValue::Text("https://github.com/warp/warp/pull/456".to_string());
assert_eq!(
ContextChipKind::GithubPullRequest.display_value(&value),
"PR #456"
);
}
#[test]
fn test_github_pr_chip_display_value_falls_back_to_raw_value() {
let value = crate::context_chips::ChipValue::Text("https://example.com/not-a-pr".to_string());
assert_eq!(
ContextChipKind::GithubPullRequest.display_value(&value),
"https://example.com/not-a-pr"
);
}
#[test]
fn test_parse_from_git_output_both_additions_and_deletions() {
let input = " 3 files changed, 5 insertions(+), 2 deletions(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 3);
assert_eq!(result.lines_added, 5);
assert_eq!(result.lines_removed, 2);
}
#[test]
fn test_parse_from_git_output_only_additions() {
let input = " 2 files changed, 10 insertions(+)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 2);
assert_eq!(result.lines_added, 10);
assert_eq!(result.lines_removed, 0);
}
#[test]
fn test_parse_from_git_output_only_deletions() {
let input = " 1 file changed, 7 deletions(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 1);
assert_eq!(result.lines_added, 0);
assert_eq!(result.lines_removed, 7);
}
#[test]
fn test_parse_from_git_output_single_values() {
// Test singular forms (1 file, 1 insertion, 1 deletion)
let input = " 1 file changed, 1 insertion(+), 1 deletion(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 1);
assert_eq!(result.lines_added, 1);
assert_eq!(result.lines_removed, 1);
}
#[test]
fn test_parse_from_git_output_no_leading_spaces() {
// Git output sometimes doesn't have leading spaces
let input = "2 files changed, 3 insertions(+), 1 deletion(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 2);
assert_eq!(result.lines_added, 3);
assert_eq!(result.lines_removed, 1);
}
#[test]
fn test_parse_from_git_output_extra_whitespace() {
// Test with extra whitespace and tabs
let input = "\t 1 file changed, 5 insertions(+), \t 3 deletions(-) ";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 1);
assert_eq!(result.lines_added, 5);
assert_eq!(result.lines_removed, 3);
}
#[test]
fn test_parse_from_git_output_empty_string() {
let input = "";
let result = GitLineChanges::parse_from_git_output(input);
assert!(result.is_none());
}
#[test]
fn test_parse_from_git_output_whitespace_only() {
let input = " \t\n ";
let result = GitLineChanges::parse_from_git_output(input);
assert!(result.is_none());
}
#[test]
fn test_parse_from_git_output_invalid_format() {
let input = "This is not a valid git diff --shortstat output";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
// Should parse but find no valid numbers
assert_eq!(result.files_changed, 0);
assert_eq!(result.lines_added, 0);
assert_eq!(result.lines_removed, 0);
}
#[test]
fn test_parse_from_git_output_partial_matches() {
// Test when only some parts match the expected pattern
let input = " 2 files changed, some insertions, 3 deletions(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 2);
assert_eq!(result.lines_added, 0); // "some" is not a number
assert_eq!(result.lines_removed, 3);
}
#[test]
fn test_parse_from_git_output_zero_changes() {
// Edge case: explicit zero values (unlikely from git but good to test)
let input = " 0 files changed, 0 insertions(+), 0 deletions(-)";
let result = GitLineChanges::parse_from_git_output(input).unwrap();
assert_eq!(result.files_changed, 0);
assert_eq!(result.lines_added, 0);
assert_eq!(result.lines_removed, 0);
}
// Tests for truncate_from_beginning function
// These tests ensure the function properly handles Unicode characters and doesn't panic
#[test]
fn test_truncate_from_beginning_ascii_short_text() {
let text = "hello";
let result = truncate_from_beginning(text, 10);
assert_eq!(result, "hello");
}
#[test]
fn test_truncate_from_beginning_ascii_exact_length() {
let text = "hello";
let result = truncate_from_beginning(text, 5);
assert_eq!(result, "hello");
}
#[test]
fn test_truncate_from_beginning_ascii_truncation() {
let text = "hello world";
let result = truncate_from_beginning(text, 10);
assert_eq!(result, "…llo world");
}
#[test]
fn test_truncate_from_beginning_unicode_multibyte_chars() {
// Test with multibyte Unicode characters (café has é which is 2 bytes in UTF-8)
let text = "café";
let result = truncate_from_beginning(text, 3);
assert_eq!(result, "…fé");
}
#[test]
fn test_truncate_from_beginning_emoji() {
// Test with emoji characters (each emoji is multiple bytes)
let text = "hello🚀world";
let result = truncate_from_beginning(text, 10);
assert_eq!(result, "…llo🚀world");
}
#[test]
fn test_truncate_from_beginning_mixed_unicode() {
// Test with mixed ASCII, multibyte chars, and emoji
let text = "café🚀test";
let result = truncate_from_beginning(text, 8);
assert_eq!(result, "…fé🚀test");
}
#[test]
fn test_truncate_from_beginning_chinese_characters() {
// Test with Chinese characters (each is typically 3 bytes in UTF-8)
let text = "世界你好世界";
let result = truncate_from_beginning(text, 5);
assert_eq!(result, "…你好世界");
}
#[test]
fn test_truncate_from_beginning_cyrillic_characters() {
// Test with Cyrillic characters
let text = "Привет";
let result = truncate_from_beginning(text, 4);
assert_eq!(result, "…вет");
}
#[test]
fn test_truncate_from_beginning_max_length_one() {
// Edge case: max_length = 1 should just return ellipsis
let text = "hello";
let result = truncate_from_beginning(text, 1);
assert_eq!(result, "");
}
#[test]
fn test_truncate_from_beginning_max_length_zero() {
// Edge case: max_length = 0 should return empty string
let text = "hello";
let result = truncate_from_beginning(text, 0);
assert_eq!(result, "");
}
#[test]
fn test_truncate_from_beginning_empty_string() {
// Edge case: empty string
let text = "";
let result = truncate_from_beginning(text, 5);
assert_eq!(result, "");
}
#[test]
fn test_truncate_from_beginning_single_unicode_char() {
// Test with single Unicode character that's longer than max_length
let text = "🚀";
let result = truncate_from_beginning(text, 1);
assert_eq!(result, "🚀"); // Should not truncate since it's already 1 character
}
#[test]
fn test_truncate_from_beginning_complex_emoji() {
// Test with complex emoji sequences (flags, skin tones, etc.)
// Note: chars()-based truncation doesn't respect grapheme cluster boundaries,
// so complex emoji like 👨‍👩‍👧‍👦 (7 chars with ZWJs) can be split mid-sequence.
// We pick max_length=5 to land on a clean emoji boundary (👦, not a ZWJ).
let text = "🚀🇺🇸🏳️‍🌈👨‍👩‍👧‍👦123";
let result = truncate_from_beginning(text, 5);
assert_eq!(result, "…👦123");
}
#[test]
fn test_truncate_from_beginning_long_path() {
// Test with a realistic scenario: long file path
let text = "/home/user/projects/my-awesome-project/src/components/display_chip.rs";
let result = truncate_from_beginning(text, 40);
assert_eq!(result, "…-project/src/components/display_chip.rs");
}
#[test]
fn test_truncate_from_beginning_windows_path() {
// Test with Windows path containing Unicode
let text = "C:\\Users\\用户\\Documents\\项目\\test.txt";
let result = truncate_from_beginning(text, 20);
assert_eq!(result, "…cuments\\项目\\test.txt");
}
#[test]
fn test_truncate_from_beginning_preserves_char_boundaries() {
// This is the critical test - ensure we never panic on char boundaries
// Test various lengths to ensure we never hit a bad boundary
let text = "café🚀世界test";
// Try every possible max_length to ensure no panics
for max_len in 1..=text.chars().count() + 5 {
let result = truncate_from_beginning(text, max_len);
// Verify the result is valid UTF-8 (won't panic if it is)
assert!(
result.chars().count() <= max_len || result.chars().count() == text.chars().count()
);
}
}
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
use std::sync::mpsc;
#[cfg(not(test))]
use std::sync::OnceLock;
#[cfg(not(target_family = "wasm"))]
use std::{
fs::{self, File, OpenOptions},
io::{self, Write as _},
path::PathBuf,
};
use chrono::{Local, SecondsFormat};
#[cfg(test)]
use parking_lot::Mutex;
use warp_completer::completer::{CommandExitStatus, CommandOutput};
#[cfg(test)]
use std::sync::Arc;
use crate::terminal::shell::ShellType;
use super::ContextChipKind;
const EMPTY_VALUE: &str = "<empty>";
const MISSING_VALUE: &str = "<none>";
pub(crate) struct ChipCommandLogEntry<'a> {
pub chip_kind: &'a ContextChipKind,
pub chip_title: &'a str,
pub phase: PromptChipExecutionPhase,
pub shell_type: ShellType,
pub working_directory: Option<&'a str>,
pub command: &'a str,
pub output: Option<&'a CommandOutput>,
pub timed_out: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum PromptChipExecutionPhase {
Value,
OnClick,
}
impl PromptChipExecutionPhase {
fn as_str(self) -> &'static str {
match self {
Self::Value => "value",
Self::OnClick => "on_click",
}
}
}
#[derive(Clone)]
pub(crate) enum PromptChipLogger {
Disabled,
Runtime {
sender: mpsc::Sender<String>,
},
#[cfg(test)]
TestBuffer {
entries: Arc<Mutex<Vec<String>>>,
},
}
impl Default for PromptChipLogger {
fn default() -> Self {
Self::shared()
}
}
impl PromptChipLogger {
pub(crate) fn shared() -> Self {
cfg_if::cfg_if! {
if #[cfg(test)] {
Self::Disabled
} else {
static SHARED_LOGGER: OnceLock<PromptChipLogger> = OnceLock::new();
SHARED_LOGGER.get_or_init(Self::init_runtime).clone()
}
}
}
#[cfg(test)]
pub(crate) fn with_test_buffer(entries: Arc<Mutex<Vec<String>>>) -> Self {
Self::TestBuffer { entries }
}
pub(crate) fn log_shell_command(&self, entry: &ChipCommandLogEntry<'_>) {
let formatted = format_log_entry(entry);
match self {
Self::Disabled => {}
Self::Runtime { sender } => {
let _ = sender.send(formatted);
}
#[cfg(test)]
Self::TestBuffer { entries } => {
entries.lock().push(formatted);
}
}
}
#[cfg(not(target_family = "wasm"))]
fn init_runtime() -> Self {
if !warp_core::channel::ChannelState::enable_debug_features() {
return Self::Disabled;
}
let log_path = match log_file_path() {
Ok(log_path) => log_path,
Err(err) => {
log::warn!("Failed to determine prompt chip log file path: {err:#}");
return Self::Disabled;
}
};
match spawn_log_writer(log_path.clone()) {
Ok(sender) => Self::Runtime { sender },
Err(err) => {
log::warn!(
"Failed to initialize prompt chip log writer at {}: {err:#}",
log_path.display()
);
Self::Disabled
}
}
}
#[cfg(target_family = "wasm")]
fn init_runtime() -> Self {
Self::Disabled
}
}
#[cfg(not(target_family = "wasm"))]
pub(crate) fn log_file_path() -> anyhow::Result<PathBuf> {
let log_directory = warp_logging::log_directory()?;
let channel_logfile_name = warp_core::channel::ChannelState::logfile_name();
Ok(log_directory.join(prompt_chip_log_filename(&channel_logfile_name)))
}
#[cfg(not(target_family = "wasm"))]
fn spawn_log_writer(log_path: PathBuf) -> io::Result<mpsc::Sender<String>> {
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)?;
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("prompt-chip-log-writer".to_string())
.spawn(move || write_log_entries(file, rx, log_path))
.map_err(io::Error::other)?;
Ok(tx)
}
#[cfg(not(target_family = "wasm"))]
fn write_log_entries(mut file: File, rx: mpsc::Receiver<String>, log_path: PathBuf) {
while let Ok(entry) = rx.recv() {
if let Err(err) = file.write_all(entry.as_bytes()).and_then(|_| file.flush()) {
log::error!(
"Failed to write prompt chip log entry to {}: {err:#}",
log_path.display()
);
return;
}
}
}
fn prompt_chip_log_filename(channel_logfile_name: &str) -> String {
let channel_logfile_stem = channel_logfile_name
.strip_suffix(".log")
.unwrap_or(channel_logfile_name);
format!("{channel_logfile_stem}.prompt_chips.log")
}
fn format_log_entry(entry: &ChipCommandLogEntry<'_>) -> String {
let timestamp = Local::now().to_rfc3339_opts(SecondsFormat::Millis, false);
let status = if entry.timed_out {
"timed_out"
} else if entry
.output
.is_some_and(|output| output.status == CommandExitStatus::Success)
{
"success"
} else {
"failure"
};
let exit_code = entry
.output
.and_then(CommandOutput::exit_code)
.map(|exit_code| exit_code.to_string())
.unwrap_or_else(|| MISSING_VALUE.to_string());
let stdout = entry
.output
.map_or(&[][..], |output| output.stdout.as_slice());
let stderr = entry
.output
.map_or(&[][..], |output| output.stderr.as_slice());
format!(
"\
===== PROMPT CHIP EXECUTION BEGIN =====
timestamp: {timestamp}
chip_kind: {:?}
chip_title: {}
phase: {}
shell_type: {:?}
working_directory: {}
status: {status}
timed_out: {}
exit_code: {exit_code}
{}
{}
{}
===== PROMPT CHIP EXECUTION END =====
",
entry.chip_kind,
entry.chip_title,
entry.phase.as_str(),
entry.shell_type,
format_scalar_field(entry.working_directory),
entry.timed_out,
format_text_block("command", "COMMAND", entry.command),
format_bytes_block("stdout", "STDOUT", stdout),
format_bytes_block("stderr", "STDERR", stderr),
)
}
fn format_scalar_field(value: Option<&str>) -> &str {
value.unwrap_or(MISSING_VALUE)
}
fn format_text_block(label: &str, marker: &str, content: &str) -> String {
format_block(label, marker, content)
}
fn format_bytes_block(label: &str, marker: &str, content: &[u8]) -> String {
let content = if content.is_empty() {
EMPTY_VALUE.to_string()
} else {
String::from_utf8_lossy(content).into_owned()
};
format_block(label, marker, &content)
}
fn format_block(label: &str, marker: &str, content: &str) -> String {
let content = if content.is_empty() {
EMPTY_VALUE
} else {
content
};
let mut output = String::new();
output.push_str(label);
output.push_str(":\n<<<");
output.push_str(marker);
output.push('\n');
output.push_str(content);
if !content.ends_with('\n') {
output.push('\n');
}
output.push_str(">>>");
output.push_str(marker);
output.push('\n');
output
}
#[cfg(test)]
#[path = "logging_tests.rs"]
mod tests;
+65
View File
@@ -0,0 +1,65 @@
use super::*;
use warp_completer::completer::{CommandExitStatus, CommandOutput};
#[test]
fn test_prompt_chip_log_filename_uses_channel_logfile_stem() {
assert_eq!(
prompt_chip_log_filename("warp_dev.log"),
"warp_dev.prompt_chips.log"
);
assert_eq!(
prompt_chip_log_filename("warp_local"),
"warp_local.prompt_chips.log"
);
}
#[test]
fn test_format_log_entry_uses_explicit_empty_and_missing_markers() {
let entry = format_log_entry(&ChipCommandLogEntry {
chip_kind: &ContextChipKind::GithubPullRequest,
chip_title: "GitHub Pull Request",
phase: PromptChipExecutionPhase::Value,
shell_type: ShellType::Zsh,
working_directory: None,
command: "",
output: None,
timed_out: true,
});
assert!(entry.contains("status: timed_out"));
assert!(entry.contains("working_directory: <none>"));
assert!(entry.contains("exit_code: <none>"));
assert!(entry.contains("command:\n<<<COMMAND\n<empty>\n>>>COMMAND"));
assert!(entry.contains("stdout:\n<<<STDOUT\n<empty>\n>>>STDOUT"));
assert!(entry.contains("stderr:\n<<<STDERR\n<empty>\n>>>STDERR"));
}
#[test]
fn test_format_log_entry_preserves_stdout_and_stderr_sections() {
let output = CommandOutput {
stdout: b"https://github.com/warpdotdev/warp-internal/pull/123\n".to_vec(),
stderr: b"warning output\n".to_vec(),
status: CommandExitStatus::Success,
exit_code: Some(warp_core::command::ExitCode::from(0)),
};
let entry = format_log_entry(&ChipCommandLogEntry {
chip_kind: &ContextChipKind::GithubPullRequest,
chip_title: "GitHub Pull Request",
phase: PromptChipExecutionPhase::OnClick,
shell_type: ShellType::Zsh,
working_directory: Some("/tmp/project"),
command: "gh pr view --json url --jq .url",
output: Some(&output),
timed_out: false,
});
assert!(entry.contains("phase: on_click"));
assert!(entry.contains("status: success"));
assert!(entry.contains("working_directory: /tmp/project"));
assert!(entry.contains("command:\n<<<COMMAND\ngh pr view --json url --jq .url\n>>>COMMAND"));
assert!(entry.contains(
"stdout:\n<<<STDOUT\nhttps://github.com/warpdotdev/warp-internal/pull/123\n>>>STDOUT"
));
assert!(entry.contains("stderr:\n<<<STDERR\nwarning output\n>>>STDERR"));
}
+726
View File
@@ -0,0 +1,726 @@
// TODO: restrict what we make public here.
mod builtins;
pub mod context_chip;
pub mod current_prompt;
pub mod directory_fetcher;
pub mod display;
pub mod display_chip;
pub mod display_menu;
pub(crate) mod logging;
pub mod node_version_popup;
pub mod prompt;
pub mod prompt_snapshot;
pub mod prompt_type;
pub mod renderer;
pub mod spacing;
use std::collections::HashMap;
use std::time::Duration;
use context_chip::PromptGenerator;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use warpui::{
color::ColorU,
elements::Text,
fonts::{Properties, Weight},
};
use crate::ui_components::{blended_colors, icons::Icon};
use crate::{appearance::Appearance, features::FeatureFlag, themes::theme::PromptColors};
#[allow(unused_imports)]
pub use self::context_chip::{
ChipAvailability, ChipDisabledReason, ChipRuntimeCapabilities, ExternalCommandsAvailability,
};
use self::{
context_chip::{ChipFingerprintInput, ChipRuntimePolicy, ContextChip, RefreshConfig},
renderer::RendererStyles,
};
/// The value of a context chip. Most chips produce plain text, but some
/// (like `GitDiffStats`) carry structured data to avoid string round-trips.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ChipValue {
Text(String),
GitDiffStats(display_chip::GitLineChanges),
}
impl ChipValue {
/// Returns the text representation, or `None` for non-text variants.
pub fn as_text(&self) -> Option<&str> {
match self {
ChipValue::Text(s) => Some(s),
ChipValue::GitDiffStats(_) => None,
}
}
/// Returns the `GitLineChanges` payload, if this is the `GitDiffStats` variant.
pub fn as_git_diff_stats(&self) -> Option<&display_chip::GitLineChanges> {
match self {
ChipValue::GitDiffStats(g) => Some(g),
ChipValue::Text(_) => None,
}
}
}
impl Default for ChipValue {
fn default() -> Self {
ChipValue::Text(String::new())
}
}
impl std::fmt::Display for ChipValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChipValue::Text(s) => f.write_str(s),
ChipValue::GitDiffStats(g) => {
write!(
f,
"{} • +{} -{}",
g.files_changed, g.lines_added, g.lines_removed
)
}
}
}
}
impl From<String> for ChipValue {
fn from(s: String) -> Self {
ChipValue::Text(s)
}
}
pub(crate) fn github_pr_number_from_url(url: &str) -> Option<&str> {
let (_, tail) = url.trim().rsplit_once("/pull/")?;
let number = tail.split(['/', '?', '#']).next()?;
(!number.is_empty() && number.chars().all(|c| c.is_ascii_digit())).then_some(number)
}
pub(crate) fn github_pr_display_text_from_url(url: &str) -> Option<String> {
github_pr_number_from_url(url).map(|number| format!("PR #{number}"))
}
/// The refresh settings for the date context chip.
/// unless the clock strikes midnight without the user running a command.
const DATE_REFRESH_CONFIG: RefreshConfig = RefreshConfig::Periodically {
interval: Duration::from_secs(30 * 60),
};
/// The refresh settings for the time context chip.
const TIME_REFRESH_CONFIG: RefreshConfig = RefreshConfig::Periodically {
interval: Duration::from_secs(1),
};
/// Refresh settings for Git context chips.
const GIT_REFRESH_CONFIG: RefreshConfig =
// TODO: Should we watch .git/HEAD instead? Needs to be relative to the current Git repo.
RefreshConfig::Periodically {
interval: Duration::from_secs(30),
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChipResult {
kind: ContextChipKind,
value: Option<ChipValue>,
on_click_values: Vec<String>,
}
impl ChipResult {
pub fn kind(&self) -> &ContextChipKind {
&self.kind
}
pub fn value(&self) -> Option<&ChipValue> {
self.value.as_ref()
}
pub fn on_click_values(&self) -> &[String] {
&self.on_click_values
}
}
#[derive(
Serialize,
Deserialize,
Clone,
Debug,
Eq,
PartialEq,
Hash,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "Type of prompt context chip.",
rename_all = "snake_case"
)]
pub enum ContextChipKind {
WorkingDirectory,
Username,
Hostname,
Date,
Time12,
Time24,
VirtualEnvironment,
CondaEnvironment,
NodeVersion,
#[schemars(description = "A user-defined custom chip.")]
Custom {
title: String,
},
ShellGitBranch,
GitDiffStats,
GithubPullRequest,
KubernetesContext,
SvnBranch,
SvnDirtyItems,
// This is for backwards compatibility with the old "RemoteLogin" chip.
// We originally had two different chips for different input types, this has since been consolidated.
#[serde(alias = "RemoteLogin")]
Ssh,
Subshell,
/// A chip that shows the plan and todo list for the current conversation.
AgentPlanAndTodoList,
}
impl ContextChipKind {
pub fn to_chip(&self) -> Option<ContextChip> {
match self {
Self::WorkingDirectory => Some(ContextChip::builtin_with_runtime_policy(
"Working Directory",
builtins::working_directory,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::WorkingDirectory,
],
),
)),
Self::Username => Some(ContextChip::builtin_with_runtime_policy(
"User",
builtins::username,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[ChipFingerprintInput::SessionId],
),
)),
Self::Hostname => Some(ContextChip::builtin_with_runtime_policy(
"Host",
builtins::hostname,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[ChipFingerprintInput::SessionId],
),
)),
Self::VirtualEnvironment => Some(ContextChip::builtin_with_runtime_policy(
"Python Virtualenv",
builtins::virtual_environment,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::PythonVirtualenv,
],
),
)),
Self::CondaEnvironment => Some(ContextChip::builtin_with_runtime_policy(
"Conda Environment",
builtins::conda_environment,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::CondaEnvironment,
],
),
)),
Self::NodeVersion => Some(ContextChip::builtin_with_runtime_policy(
"Node.js Version",
builtins::node_version,
RefreshConfig::OnDemandOnly,
ChipRuntimePolicy::new(
std::iter::empty::<&str>(),
false,
None,
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::NodeVersion,
],
),
)),
Self::Date => Some(ContextChip::builtin(
"Date",
builtins::date,
DATE_REFRESH_CONFIG,
)),
Self::Time12 => Some(ContextChip::builtin(
"Time (12-hour format)",
builtins::time12,
TIME_REFRESH_CONFIG,
)),
Self::Time24 => Some(ContextChip::builtin(
"Time (24-hour format)",
builtins::time24,
TIME_REFRESH_CONFIG,
)),
Self::Custom { title } => {
log::warn!("Tried to use custom chip {title}");
None
}
Self::ShellGitBranch => Some(ContextChip::shell_builtin(
"Git Branch",
builtins::shell_git_branch(),
Some(builtins::shell_other_git_branches()),
GIT_REFRESH_CONFIG,
)),
Self::GitDiffStats => Some(
ContextChip::shell_builtin(
"Git Diff Stats",
builtins::shell_git_line_changes(),
None,
GIT_REFRESH_CONFIG,
)
.with_allow_empty_value(),
),
Self::GithubPullRequest if !FeatureFlag::GithubPrPromptChip.is_enabled() => None,
Self::GithubPullRequest => {
let generator = builtins::github_pull_request_url();
let policy = ChipRuntimePolicy::new(
generator.dependencies().to_vec(),
true,
Some(Duration::from_secs(5)),
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::WorkingDirectory,
ChipFingerprintInput::GitBranch,
ChipFingerprintInput::RequiredExecutablesPresence,
ChipFingerprintInput::InvalidatingCommandCount,
],
)
.with_suppress_on_failure()
.with_invalidate_on_commands(["git", "gh", "gt"]);
Some(ContextChip::shell_builtin_with_runtime_policy(
"GitHub Pull Request",
generator,
None,
GIT_REFRESH_CONFIG,
policy,
))
}
Self::KubernetesContext => Some(ContextChip::shell_builtin(
"Kubernetes Context",
builtins::kubernetes_current_context(),
None,
RefreshConfig::OnDemandOnly,
)),
Self::SvnBranch => Some(ContextChip::shell_builtin(
"Svn Branch",
builtins::svn_branch_context(),
None,
RefreshConfig::OnDemandOnly,
)),
Self::SvnDirtyItems => Some(ContextChip::shell_builtin(
"Svn Uncommited File Count",
builtins::svn_dirty_items(),
None,
RefreshConfig::OnDemandOnly,
)),
Self::Ssh => Some(ContextChip::builtin(
"Remote Login",
builtins::ssh_session,
RefreshConfig::OnDemandOnly,
)),
Self::Subshell => Some(ContextChip::builtin(
"subshell",
builtins::subshell,
RefreshConfig::OnDemandOnly,
)),
Self::AgentPlanAndTodoList => Some(ContextChip::builtin(
"Agent Plan and Todo List",
|_| Some(ChipValue::Text(String::new())),
RefreshConfig::OnDemandOnly,
)),
}
}
/// Whether the context chip has a copyable value.
pub fn is_copyable(&self) -> bool {
!matches!(self, Self::AgentPlanAndTodoList)
}
/// Returns a generator to be used for the first fetch of
/// a periodic generator. Is mostly used to use the PreCmd value
/// of git-branch for ShellGitBranch, while using a shell command
/// for the periodic updates.
pub fn initial_value_generator(&self) -> Option<PromptGenerator> {
match self {
Self::ShellGitBranch => Some(PromptGenerator::Contextual {
from_context_fn: |context| {
context
.current_environment
.git_branch()
.map(|s| ChipValue::Text(s.to_string()))
},
}),
_ => None,
}
}
/// TODO: we might need to move this API to support custom chips.
pub fn placeholder_value(&self) -> ChipValue {
match self {
Self::WorkingDirectory => ChipValue::Text("~/Desktop".to_string()),
Self::Username => ChipValue::Text("alice".to_string()),
Self::Hostname => ChipValue::Text("ubuntu-04".to_string()),
Self::ShellGitBranch => ChipValue::Text("git-feature-branch".to_string()),
Self::GitDiffStats => ChipValue::Text("3 • +10 -2".to_string()),
Self::GithubPullRequest => ChipValue::Text("PR #123".to_string()),
Self::VirtualEnvironment => ChipValue::Text("pyenv".to_string()),
Self::CondaEnvironment => ChipValue::Text("condaenv".to_string()),
Self::NodeVersion => ChipValue::Text("v18.17.0".to_string()),
Self::Date => ChipValue::Text("July 12, 2023".to_string()),
Self::Time12 => ChipValue::Text("03:48 pm".to_string()),
Self::Time24 => ChipValue::Text("15:48".to_string()),
Self::Custom { .. } => ChipValue::Text("custom chip".to_string()),
Self::KubernetesContext => ChipValue::Text("kube-context".to_string()),
Self::SvnBranch => ChipValue::Text("svn-feature-branch".to_string()),
Self::SvnDirtyItems => ChipValue::Text("3".to_string()),
Self::Ssh => ChipValue::Text("alice@127.0.0.1".to_string()),
Self::Subshell => ChipValue::Text("bash".to_string()),
Self::AgentPlanAndTodoList => ChipValue::Text("Plan and Todo List".to_string()),
}
}
pub fn default_styles(
&self,
appearance: &Appearance,
is_in_agent_view: bool,
) -> RendererStyles {
if is_in_agent_view {
return RendererStyles::new(agent_view_chip_color(appearance), Properties::default());
}
let prompt_colors: PromptColors = appearance.theme().clone().into();
let color = match self {
Self::WorkingDirectory => prompt_colors.input_prompt_pwd,
Self::Username => prompt_colors.input_prompt_user_and_host,
Self::Hostname => prompt_colors.input_prompt_user_and_host,
Self::ShellGitBranch => prompt_colors.input_prompt_branch,
Self::GitDiffStats => prompt_colors.input_prompt_branch,
Self::GithubPullRequest => prompt_colors.input_prompt_branch,
Self::VirtualEnvironment => prompt_colors.input_prompt_virtual_env,
Self::CondaEnvironment => prompt_colors.input_prompt_virtual_env,
Self::NodeVersion => prompt_colors.input_prompt_virtual_env,
Self::Date => prompt_colors.input_prompt_date,
Self::Time12 => prompt_colors.input_prompt_time,
Self::Time24 => prompt_colors.input_prompt_time,
Self::KubernetesContext => prompt_colors.input_prompt_kubernetes,
Self::SvnBranch => prompt_colors.input_prompt_branch,
Self::SvnDirtyItems => prompt_colors.input_prompt_svn,
Self::Ssh => prompt_colors.input_prompt_ssh,
Self::Subshell => prompt_colors.input_prompt_subshell,
Self::AgentPlanAndTodoList => prompt_colors.input_prompt_agent_mode_hint,
Self::Custom { .. } => ColorU::new(255, 255, 255, 255),
};
let font_properties = Properties::default().weight(Weight::Semibold);
RendererStyles::new(color, font_properties)
}
/// The name of this context chip to use in telemetry, or `None` if it should not
/// be reported at all.
///
/// This lets us measure which chips are being used without reporting private
/// user-created chips.
pub fn telemetry_name(&self) -> Option<String> {
match self {
Self::Custom { .. } => None,
chip => Some(format!("{chip:?}")),
}
}
/// Formats a value of this context chip for display.
///
/// This is temporary until chip prefixes/suffixes are user-configurable.
/// Keep in sync with [`display::PromptDisplay`].
pub fn display_value(&self, value: &ChipValue) -> String {
let text = value.to_string();
match self {
Self::ShellGitBranch => format!("git:({text})"),
Self::GithubPullRequest => github_pr_display_text_from_url(&text).unwrap_or(text),
Self::KubernetesContext => format!("{text}"),
Self::SvnBranch => format!("svn:({text})"),
Self::SvnDirtyItems => format!("±{text}"),
_ => text,
}
}
/// Whether or not a context chip should render given the current command
/// in the input.
pub fn should_render(&self, command: &str, aliases: &HashMap<SmolStr, String>) -> bool {
match self {
Self::KubernetesContext => {
const KUBERNETES_COMMANDS: [&str; 20] = [
"kubectl",
"helm",
"kubens",
"kubectx",
"oc",
"istioctl",
"kogito",
"k9s",
"helmfile",
"flux",
"fluxctl",
"stern",
"kubeseal",
"skaffold",
"kubent",
"kubecolor",
"cmctl",
"sparkctl",
"etcd",
"fubectl",
];
command.split_whitespace().next().is_some_and(|first_word| {
KUBERNETES_COMMANDS.contains(&first_word)
|| aliases.get(first_word).is_some_and(|expanded| {
KUBERNETES_COMMANDS.contains(&expanded.as_str())
})
})
}
// All other chips unconditionally render
_ => true,
}
}
pub fn udi_icon(&self) -> Option<Icon> {
match self {
Self::WorkingDirectory => Some(Icon::Folder),
Self::Username | Self::Ssh => Some(Icon::User),
Self::Hostname => Some(Icon::Laptop),
Self::Date => Some(Icon::CalendarDate),
Self::Time12 | Self::Time24 => Some(Icon::Clock),
Self::VirtualEnvironment | Self::CondaEnvironment | Self::Subshell => {
Some(Icon::Terminal)
}
Self::NodeVersion => Some(Icon::NodeJS),
Self::ShellGitBranch | Self::SvnBranch => Some(Icon::GitBranch),
Self::GitDiffStats | Self::SvnDirtyItems => Some(Icon::File),
Self::GithubPullRequest => Some(Icon::Github),
Self::KubernetesContext => Some(Icon::Globe),
Self::AgentPlanAndTodoList => Some(Icon::CheckSkinny),
Self::Custom { .. } => None,
}
}
}
/// Returns the set of chips that are available for use in the agent footer.
pub fn agent_footer_available_chips() -> Vec<ContextChipKind> {
let mut chips = available_chips();
chips.push(ContextChipKind::AgentPlanAndTodoList);
chips
}
/// TODO: this needs to also fetch the custom chips from sqlite
pub fn available_chips() -> Vec<ContextChipKind> {
let mut chips = vec![
ContextChipKind::WorkingDirectory,
ContextChipKind::Username,
ContextChipKind::Hostname,
ContextChipKind::Ssh,
ContextChipKind::ShellGitBranch,
ContextChipKind::GitDiffStats,
];
if FeatureFlag::GithubPrPromptChip.is_enabled() {
chips.push(ContextChipKind::GithubPullRequest);
}
chips.extend([
ContextChipKind::Date,
ContextChipKind::Time12,
ContextChipKind::Time24,
ContextChipKind::VirtualEnvironment,
ContextChipKind::CondaEnvironment,
ContextChipKind::NodeVersion,
ContextChipKind::KubernetesContext,
ContextChipKind::SvnBranch,
ContextChipKind::SvnDirtyItems,
]);
chips
}
/// Parses [`display_chip::GitLineChanges`] from the raw shell command output stored in a
/// [`GitDiffStats`](ContextChipKind::GitDiffStats) chip's value.
///
/// Used as a fallback when `GitRepoStatusModel` is unavailable (e.g. remote sessions,
/// local subshells).
pub fn git_line_changes_from_chips(chips: &[ChipResult]) -> Option<display_chip::GitLineChanges> {
chips.iter().find_map(|chip| {
if matches!(chip.kind(), ContextChipKind::GitDiffStats) {
chip.value().map(|value| match value {
// Structured data from GitRepoStatusModel — use directly.
ChipValue::GitDiffStats(g) => g.clone(),
// Raw shell command output (remote sessions) — parse.
ChipValue::Text(raw) => display_chip::GitLineChanges::parse_from_git_output(raw)
.unwrap_or(display_chip::GitLineChanges {
files_changed: 0,
lines_added: 0,
lines_removed: 0,
}),
})
} else {
None
}
})
}
/// Formats given context chips as an unstylized string.
/// Compared to displaying individual chips, certain chips when combined are displayed differently.
pub fn chips_to_string(chips: impl Iterator<Item = ChipResult>) -> String {
let mut prompt = String::new();
let mut visible_chips = chips
.into_iter()
.filter_map(|chip_result| Some((chip_result.kind, chip_result.value?)))
.peekable();
while let Some((chip_kind, current_value)) = visible_chips.next() {
// This is temporary, until we design more generic chip formatting.
let next_chip_kind = visible_chips.peek().map(|(next_kind, _)| next_kind);
let chip_display_value = chip_kind.display_value(&current_value);
prompt.push_str(&chip_display_value);
match (chip_kind, next_chip_kind) {
// Omit the space between adjacent Svn chips.
(ContextChipKind::SvnBranch, Some(ContextChipKind::SvnDirtyItems)) => (),
(_, Some(_)) => {
// Add padding after non-empty chips.
if !chip_display_value.is_empty() {
prompt.push(' ');
}
}
_ => (),
}
}
prompt
}
pub(crate) fn agent_view_chip_color(appearance: &Appearance) -> ColorU {
let theme = appearance.theme();
theme
.sub_text_color(blended_colors::neutral_1(theme).into())
.into_solid()
}
/// Helper function that adds specific styling to chips' text element.
/// Keeps chips in both editor and input in sync.
/// Keep in sync with [`ContextChipKind::display_value`]
pub fn render_text_from_kind(
text: &mut Text,
kind: ContextChipKind,
value: String,
is_in_agent_view: bool,
appearance: &Appearance,
) {
let styles = kind.default_styles(appearance, is_in_agent_view);
let prompt_colors: PromptColors = appearance.theme().clone().into();
// Keep in sync with `ContextChipKind::display_value`
match kind {
ContextChipKind::ShellGitBranch => {
text.add_text_with_highlights(
"git:(",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_git
},
styles.font_properties,
);
}
ContextChipKind::SvnBranch => {
text.add_text_with_highlights(
"svn:(",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_svn
},
styles.font_properties,
);
}
ContextChipKind::SvnDirtyItems => {
text.add_text_with_highlights(
"±",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_svn
},
styles.font_properties,
);
}
ContextChipKind::KubernetesContext => {
text.add_text_with_highlights(
"",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_kubernetes
},
if is_in_agent_view {
styles.font_properties
} else {
Properties::default().weight(Weight::Thin)
},
);
}
_ => (),
}
text.add_text_with_highlights(value, styles.value_color, styles.font_properties);
match kind {
ContextChipKind::ShellGitBranch => {
text.add_text_with_highlights(
")",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_git
},
styles.font_properties,
);
}
ContextChipKind::SvnBranch => {
text.add_text_with_highlights(
")",
if is_in_agent_view {
styles.value_color
} else {
prompt_colors.input_prompt_svn
},
styles.font_properties,
);
}
_ => (),
}
}
+598
View File
@@ -0,0 +1,598 @@
use pathfinder_color::ColorU;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::Fill;
use warpui::elements::{
ChildView, ClippedScrollStateHandle, ClippedScrollable, Dismiss, ParentElement, ScrollbarWidth,
};
use warpui::fonts::FamilyId;
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisAlignment, MainAxisSize, Radius, Text,
},
fonts::Properties,
keymap::FixedBinding,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::menu::{self, Event as MenuEvent, Menu, MenuItemFields};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::ui_components::blended_colors;
use crate::ui_components::icons;
use crate::view_components::action_button::{ActionButton, SecondaryTheme};
const MENU_WIDTH: f32 = 300.0;
const MENU_MAX_HEIGHT: f32 = 260.0;
pub struct NodeVersionPopupView {
install_button: ViewHandle<ActionButton>,
install_latest_node_button: ViewHandle<ActionButton>,
has_nvm: bool,
versions: Vec<String>,
current_version: Option<String>,
versions_menu: Option<ViewHandle<Menu<NodeVersionPopupAction>>>,
scroll_state: ClippedScrollStateHandle,
}
#[derive(Debug, Clone)]
pub enum NodeVersionPopupAction {
ClosePopup,
InstallNvm,
InstallLatestNodeVersion,
SelectVersion { version: String },
}
#[derive(Debug, Clone)]
pub enum NodeVersionPopupEvent {
Close,
InstallNvm,
InstallLatestNodeVersion,
SelectVersion { version: String },
}
struct Styles {
ui_font_family: FamilyId,
background: Fill,
main_text_color: ColorU,
secondary_text_color: ColorU,
tertiary_text_color: ColorU,
detail_font_size: f32,
}
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_fixed_bindings([FixedBinding::new(
"escape",
NodeVersionPopupAction::ClosePopup,
id!(NodeVersionPopupView::ui_name()),
)]);
}
impl NodeVersionPopupView {
pub fn new(
current_version: Option<String>,
model_events: &ModelHandle<ModelEventDispatcher>,
ctx: &mut ViewContext<Self>,
) -> Self {
let install_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Install nvm", SecondaryTheme)
.with_icon(icons::Icon::Terminal)
.on_click(|ctx| {
ctx.dispatch_typed_action(NodeVersionPopupAction::InstallNvm);
})
});
let install_latest_node_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("nvm install node", SecondaryTheme)
.with_icon(icons::Icon::Terminal)
.on_click(|ctx| {
ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion);
})
});
let has_nvm = detect_nvm_installed();
let versions = if has_nvm {
list_nvm_versions()
} else {
Vec::new()
};
let versions_menu = if has_nvm {
let menu_handle = ctx.add_typed_action_view(|ctx| {
let mut menu = Menu::new().with_width(MENU_WIDTH);
menu.set_items(Self::menu_items(&versions, current_version.as_deref()), ctx);
let selected_index =
get_selected_version_index(&versions, current_version.as_deref());
menu.set_selected_by_index(selected_index, ctx);
menu
});
ctx.subscribe_to_view(&menu_handle, |_, _, event, ctx| {
if let MenuEvent::Close { .. } = event {
ctx.emit(NodeVersionPopupEvent::Close);
}
});
Some(menu_handle)
} else {
None
};
// Subscribe to command execution events to refresh
// when nvm is installed or a node version is installed
ctx.subscribe_to_model(model_events, |me, _model, event, ctx| match event {
ModelEvent::ExecutedInBandCommand(_) | ModelEvent::AfterBlockCompleted(_) => {
me.refresh(ctx);
}
_ => {}
});
Self {
install_button,
install_latest_node_button,
has_nvm,
versions,
current_version,
versions_menu,
scroll_state: Default::default(),
}
}
fn styles(&self, appearance: &Appearance) -> Styles {
let theme = appearance.theme();
let background = theme.surface_2();
let main_text_color = blended_colors::text_main(theme, background);
let secondary_text_color = blended_colors::text_sub(theme, background);
let tertiary_text_color = theme.hint_text_color(background).into_solid();
let detail_font_size = appearance.ui_font_size();
let ui_font_family = appearance.ui_font_family();
Styles {
ui_font_family,
background,
main_text_color,
secondary_text_color,
tertiary_text_color,
detail_font_size,
}
}
fn render_install_nvm_empty_state(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let styles = self.styles(appearance);
let mut col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
col.add_child(
Container::new(
ConstrainedBox::new(
icons::Icon::NodeJS
.to_warpui_icon(styles.tertiary_text_color.into())
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish(),
)
.with_margin_bottom(12.)
.finish(),
);
col.add_child(
Text::new(
"Install nvm to enable version switching",
styles.ui_font_family,
styles.detail_font_size + 2.,
)
.with_style(Properties::default())
.with_color(styles.secondary_text_color)
.finish(),
);
col.add_child(
Container::new(
Text::new(
"This menu helps you switch between Node.js versions — but it requires nvm to be installed.",
styles.ui_font_family,
styles.detail_font_size,
)
.with_color(styles.tertiary_text_color)
.soft_wrap(true)
.finish(),
)
.with_margin_top(6.)
.with_margin_bottom(12.)
.with_horizontal_padding(24.)
.finish(),
);
col.add_child(ChildView::new(&self.install_button).finish());
ConstrainedBox::new(col.finish())
.with_max_width(MENU_WIDTH)
.with_max_height(MENU_MAX_HEIGHT)
.finish()
}
fn render_install_latest_node_version_empty_state(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let styles = self.styles(appearance);
let mut col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max);
col.add_child(
Container::new(
ConstrainedBox::new(
icons::Icon::NodeJS
.to_warpui_icon(styles.tertiary_text_color.into())
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish(),
)
.with_margin_bottom(12.)
.finish(),
);
// Heading
col.add_child(
Text::new(
"No node versions installed",
styles.ui_font_family,
styles.detail_font_size + 2.,
)
.with_style(Properties::default())
.with_color(styles.secondary_text_color)
.finish(),
);
// Subheading
col.add_child(
Container::new(
Text::new(
"Try installing versions with nvm",
styles.ui_font_family,
styles.detail_font_size,
)
.with_color(styles.tertiary_text_color)
.soft_wrap(true)
.finish(),
)
.with_margin_top(6.)
.with_margin_bottom(12.)
.with_horizontal_padding(24.)
.finish(),
);
// Button
col.add_child(ChildView::new(&self.install_latest_node_button).finish());
ConstrainedBox::new(col.finish())
.with_max_width(MENU_WIDTH)
.with_max_height(MENU_MAX_HEIGHT)
.finish()
}
fn render_node_version_selector(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let styles = self.styles(appearance);
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
col.add_child(
Container::new(
Text::new("Installed", styles.ui_font_family, styles.detail_font_size)
.with_style(Properties::default())
.with_color(styles.secondary_text_color)
.finish(),
)
.with_horizontal_padding(12.)
.finish(),
);
if let Some(menu) = &self.versions_menu {
col.add_child(ChildView::new(menu).finish());
}
Container::new(col.finish()).with_padding_top(8.).finish()
}
fn menu_items(
versions: &[String],
current_version: Option<&str>,
) -> Vec<menu::MenuItem<NodeVersionPopupAction>> {
versions
.iter()
.map(|ver| {
let mut fields = MenuItemFields::new(ver).with_on_select_action(
NodeVersionPopupAction::SelectVersion {
version: ver.clone(),
},
);
if is_current_version(ver, current_version) {
fields = fields.with_icon(icons::Icon::Check);
} else {
fields = fields.with_indent();
}
menu::MenuItem::Item(fields)
})
.collect()
}
pub fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
self.has_nvm = detect_nvm_installed();
self.versions = if self.has_nvm {
list_nvm_versions()
} else {
Vec::new()
};
if let Some(menu) = &self.versions_menu {
menu.update(ctx, |menu, ctx| {
menu.set_items(
Self::menu_items(&self.versions, self.current_version.as_deref()),
ctx,
);
let selected_index =
get_selected_version_index(&self.versions, self.current_version.as_deref());
menu.set_selected_by_index(selected_index, ctx);
});
}
ctx.notify();
}
}
impl View for NodeVersionPopupView {
fn ui_name() -> &'static str {
"NodeVersionPopup"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let styles = self.styles(appearance);
let content = if !self.versions.is_empty() {
self.render_node_version_selector(app)
} else if self.has_nvm {
self.render_install_latest_node_version_empty_state(app)
} else {
self.render_install_nvm_empty_state(app)
};
let scrollable = ClippedScrollable::vertical(
self.scroll_state.clone(),
content,
ScrollbarWidth::Auto,
appearance.theme().nonactive_ui_detail().into(),
appearance.theme().active_ui_detail().into(),
warpui::elements::Fill::None,
)
.with_overlayed_scrollbar()
.finish();
Dismiss::new(
ConstrainedBox::new(
Container::new(scrollable)
.with_background(styles.background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_drop_shadow(DropShadow::default())
.finish(),
)
.with_width(MENU_WIDTH)
.with_max_height(MENU_MAX_HEIGHT)
.finish(),
)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(NodeVersionPopupAction::ClosePopup))
.finish()
}
}
impl Entity for NodeVersionPopupView {
type Event = NodeVersionPopupEvent;
}
impl NodeVersionPopupView {
pub fn focus_content(&self, ctx: &mut ViewContext<Self>) {
// Focus menu if present to allow keyboard navigation
if let Some(menu) = &self.versions_menu {
ctx.focus(menu);
} else {
ctx.focus_self();
}
}
}
impl TypedActionView for NodeVersionPopupView {
type Action = NodeVersionPopupAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
NodeVersionPopupAction::ClosePopup => ctx.emit(NodeVersionPopupEvent::Close),
NodeVersionPopupAction::InstallNvm => ctx.emit(NodeVersionPopupEvent::InstallNvm),
NodeVersionPopupAction::InstallLatestNodeVersion => {
ctx.emit(NodeVersionPopupEvent::InstallLatestNodeVersion)
}
NodeVersionPopupAction::SelectVersion { version } => {
ctx.emit(NodeVersionPopupEvent::SelectVersion {
version: version.clone(),
});
ctx.emit(NodeVersionPopupEvent::Close);
}
}
}
}
// Cross-OS detection of nvm availability
fn detect_nvm_installed() -> bool {
use std::env;
// Helper: check if an executable exists in PATH
fn in_path(candidate: &str) -> bool {
if let Ok(path_var) = env::var("PATH") {
for dir in env::split_paths(&path_var) {
let mut p = dir.clone();
p.push(candidate);
if p.is_file() {
return true;
}
}
}
false
}
// 1) Windows nvm-windows
#[cfg(windows)]
{
env::var("PATH").is_ok_and(|path_var| path_var.contains("%NVM_HOME%"))
|| env::var("NVM_HOME").is_ok()
}
// 2) POSIX shells: nvm is typically a shell function; detect via standard install locations
#[cfg(not(windows))]
{
use std::path::Path;
// NVM_DIR env var with nvm.sh present
if let Ok(nvm_dir) = env::var("NVM_DIR") {
let nvm_sh = Path::new(&nvm_dir).join("nvm.sh");
if nvm_sh.is_file() {
return true;
}
}
// Default NVM_DIR ~/.nvm/nvm.sh
if let Some(home) = dirs::home_dir() {
let nvm_sh = home.join(".nvm").join("nvm.sh");
if nvm_sh.is_file() {
return true;
}
}
// Homebrew locations
let brew_paths: &[&str] = &["/opt/homebrew/opt/nvm", "/usr/local/opt/nvm"];
for base in brew_paths {
let nvm_sh = Path::new(base).join("nvm.sh");
if nvm_sh.is_file() {
return true;
}
}
// Fish plugin-based installs
if let Some(home) = dirs::home_dir() {
let fish_nvm = home
.join(".config")
.join("fish")
.join("functions")
.join("nvm.fish");
if fish_nvm.is_file() {
return true;
}
// macOS possible alt path for fish conf sometimes under Library
let fish_alt = home
.join("Library")
.join("Application Support")
.join("fish")
.join("functions")
.join("nvm.fish");
if fish_alt.is_file() {
return true;
}
}
// If an `nvm` shim exists on PATH (rare on unix because it's a function), still check
if in_path("nvm") {
return true;
}
false
}
}
// Enumerate installed Node versions managed by nvm (best-effort, cross-OS)
fn list_nvm_versions() -> Vec<String> {
use std::env;
use std::path::Path;
let mut out: Vec<String> = Vec::new();
#[cfg(windows)]
{
if let Ok(nvm_home) = env::var("NVM_HOME") {
let base = Path::new(&nvm_home);
if let Ok(read_dir) = std::fs::read_dir(base) {
for entry in read_dir.flatten() {
if let Ok(ft) = entry.file_type() {
if ft.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
// nvm-windows typically uses folder names like v18.19.1 or 18.19.1
if name
.chars()
.next()
.map(|c| c == 'v' || c.is_ascii_digit())
.unwrap_or(false)
{
out.push(name);
}
}
}
}
}
}
}
#[cfg(not(windows))]
{
// Prefer $NVM_DIR/versions/node
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(nvm_dir) = env::var("NVM_DIR") {
candidates.push(Path::new(&nvm_dir).join("versions").join("node"));
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".nvm").join("versions").join("node"));
}
for base in candidates {
if let Ok(read_dir) = std::fs::read_dir(&base) {
for entry in read_dir.flatten() {
if let Ok(ft) = entry.file_type() {
if ft.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
out.push(name);
}
}
}
}
}
}
// Sort descending so the latest version is first
out.sort_by(|a, b| b.cmp(a));
out.dedup();
out
}
fn normalize_version(ver: &str) -> String {
ver.trim()
.strip_prefix('v')
.unwrap_or(ver.trim())
.to_string()
}
fn is_current_version(candidate: &str, current: Option<&str>) -> bool {
current.is_some_and(|cur| normalize_version(candidate) == normalize_version(cur))
}
fn get_selected_version_index(versions: &[String], current_version: Option<&str>) -> usize {
versions
.iter()
.position(|v| is_current_version(v, current_version))
.unwrap_or(0)
}
+415
View File
@@ -0,0 +1,415 @@
use crate::{
settings::{
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent,
WarpPromptSeparator,
},
terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent},
};
pub use super::ContextChipKind;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use settings::Setting as _;
use warpui::{Entity, GetSingletonModelHandle, ModelContext, SingletonEntity, UpdateModel};
#[cfg(test)]
#[path = "prompt_tests.rs"]
mod tests;
#[derive(
Clone,
Debug,
Default,
Serialize,
Deserialize,
PartialEq,
Eq,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(description = "Configuration for a prompt chip.")]
pub struct ChipConfig {
// TODO in the future
}
/// PromptChip holds the configuration of the specific chip in the prompt that the user set.
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(description = "A chip in the prompt with its configuration.")]
pub struct PromptChip {
#[schemars(description = "The type of context chip.")]
chip: ContextChipKind,
#[schemars(description = "Configuration options for this chip.")]
config: ChipConfig,
}
impl PromptChip {
fn new(chip: ContextChipKind, config: ChipConfig) -> Self {
Self { chip, config }
}
pub fn chip(&self) -> &ContextChipKind {
&self.chip
}
}
/// Deserialize prompt chips, silently dropping any with unrecognized chip kinds.
/// This ensures saved prompt configs remain intact when chip kinds are removed.
fn deserialize_prompt_chips<'de, D>(deserializer: D) -> Result<Vec<PromptChip>, D::Error>
where
D: serde::Deserializer<'de>,
{
let values: Vec<serde_json::Value> = serde::Deserialize::deserialize(deserializer)?;
Ok(values
.into_iter()
.filter_map(|value| serde_json::from_value::<PromptChip>(value).ok())
.collect())
}
/// Serializable configuration for the current prompt.
#[derive(
Clone,
Debug,
Default,
Serialize,
Deserialize,
PartialEq,
Eq,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(description = "Prompt layout configuration.")]
pub struct PromptConfiguration {
#[serde(default, deserialize_with = "deserialize_prompt_chips")]
#[schemars(description = "Ordered list of chips to display in the prompt.")]
chips: Vec<PromptChip>,
#[serde(default)]
#[schemars(description = "Whether git diff stats have been separated into their own chip.")]
did_separate_git_diff_stats: bool,
#[schemars(description = "Whether the prompt is displayed on the same line as the input.")]
same_line_prompt_enabled: bool,
/// The separator to use as a trailing character at the end of Warp prompt, if any.
#[schemars(description = "Trailing separator character for the prompt.")]
separator: WarpPromptSeparator,
}
#[derive(
Clone,
Debug,
Default,
Serialize,
Deserialize,
PartialEq,
Eq,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "Whether using the default or a custom prompt layout.",
rename_all = "snake_case"
)]
pub enum PromptSelection {
#[default]
#[schemars(description = "Use the default prompt.")]
Default,
#[schemars(description = "Use a custom prompt chip selection.")]
CustomChipSelection(PromptConfiguration),
}
impl From<PromptConfiguration> for PromptSelection {
fn from(config: PromptConfiguration) -> Self {
Self::CustomChipSelection(config)
}
}
impl PromptSelection {
pub fn same_line_prompt_enabled(&self) -> bool {
match self {
PromptSelection::Default => false,
PromptSelection::CustomChipSelection(config) => config.same_line_prompt_enabled(),
}
}
pub fn separator(&self) -> WarpPromptSeparator {
match self {
PromptSelection::Default => WarpPromptSeparator::None,
PromptSelection::CustomChipSelection(config) => config.separator(),
}
}
}
/// Prompt is the singleton entity that stores the selected prompt configuration.
pub struct Prompt {
config: PromptConfiguration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptEvent {
/// The prompt configuration changed.
Changed,
}
impl Prompt {
/// Creates a global singleton [`Prompt`] that responds to settings changes.
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let session_settings = SessionSettings::handle(ctx);
ctx.subscribe_to_model(&session_settings, Self::handle_session_settings_change);
let ai_settings = AISettings::handle(ctx);
ctx.subscribe_to_model(&ai_settings, Self::handle_ai_settings_change);
let input_settings = InputSettings::handle(ctx);
ctx.subscribe_to_model(&input_settings, Self::handle_input_settings_change);
let initial_config = Self::from_user_settings(ctx);
Self {
config: initial_config,
}
}
pub fn update<I>(
&mut self,
chips: I,
same_line_prompt_enabled: bool,
separator: WarpPromptSeparator,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()>
where
I: IntoIterator<Item = ContextChipKind>,
{
let config = PromptConfiguration::from_chips(chips, same_line_prompt_enabled, separator);
// Eagerly set the new config - it will be re-updated when the settings change propagates.
self.config = config.clone();
SessionSettings::handle(ctx).update(ctx, |session_settings, ctx| {
session_settings.honor_ps1.set_value(false, ctx)?;
session_settings
.saved_prompt
.set_value(PromptSelection::CustomChipSelection(config), ctx)?;
Ok(())
})
}
/// Reset to the default Warp prompt.
pub fn reset<C: UpdateModel + GetSingletonModelHandle>(
&mut self,
ctx: &mut C,
) -> anyhow::Result<()> {
// Note that because settings are being updated, `handle_session_settings_change` will be called
// and will set the new prompt value
let session_settings = SessionSettings::handle(ctx);
session_settings.update(ctx, |session_settings, ctx| {
session_settings
.saved_prompt
.set_value(PromptSelection::Default, ctx)?;
session_settings.honor_ps1.set_value(false, ctx)?;
Ok(())
})
}
/// Builds the [`PromptConfiguration`] from current user settings.
fn from_user_settings(ctx: &mut ModelContext<Self>) -> PromptConfiguration {
let session_settings = SessionSettings::handle(ctx);
let settings = session_settings.as_ref(ctx);
match settings.saved_prompt.clone() {
PromptSelection::Default => {
let suppress_pr = settings.github_pr_chip_default_validation.is_suppressed();
PromptConfiguration::default_prompt_with_pr_chip_suppressed(suppress_pr)
}
PromptSelection::CustomChipSelection(config) => config.normalize_custom_prompt_config(),
}
}
/// Mock an empty prompt.
#[cfg(test)]
pub fn mock() -> Self {
Self {
config: Default::default(),
}
}
/// Mock a prompt with the given chips.
#[cfg(test)]
pub fn mock_with(
chips: impl IntoIterator<Item = ContextChipKind>,
same_line_prompt_enabled: bool,
separator: WarpPromptSeparator,
) -> Self {
Self {
config: PromptConfiguration::from_chips(chips, same_line_prompt_enabled, separator),
}
}
/// Wehther same line prompt is enabled for Warp prompt.
pub fn same_line_prompt_enabled(&self) -> bool {
self.config.same_line_prompt_enabled
}
/// The separator to be used for the Warp prompt.
pub fn separator(&self) -> WarpPromptSeparator {
self.config.separator
}
/// The chips included in the prompt, in order from left to right.
pub fn chip_kinds(&self) -> Vec<ContextChipKind> {
self.config.chip_kinds()
}
/// Updates the in-memory prompt configuration to reflect a settings change.
fn handle_session_settings_change(
&mut self,
event: &SessionSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
if matches!(
event,
SessionSettingsChangedEvent::SavedPrompt { .. }
| SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. }
) {
log::debug!("Loading new prompt configuration");
self.config = Self::from_user_settings(ctx);
ctx.emit(PromptEvent::Changed);
}
}
fn handle_input_settings_change(
&mut self,
event: &InputSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
if matches!(event, InputSettingsChangedEvent::InputBoxTypeSetting { .. }) {
self.config = Self::from_user_settings(ctx);
ctx.emit(PromptEvent::Changed);
}
}
/// Updates the in-memory prompt configuration to reflect an AI settings change.
fn handle_ai_settings_change(
&mut self,
event: &AISettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
if let AISettingsChangedEvent::IsAnyAIEnabled { .. } = event {
log::debug!("Loading new prompt configuration");
self.config = Self::from_user_settings(ctx);
ctx.emit(PromptEvent::Changed);
}
}
/// Updates the in-memory prompt configuration to reflect an AI input model change.
fn handle_ai_input_model_change(&mut self, ctx: &mut ModelContext<Self>) {
log::debug!("Loading new prompt configuration due to AI input model change");
self.config = Self::from_user_settings(ctx);
ctx.emit(PromptEvent::Changed);
}
}
impl Entity for Prompt {
type Event = PromptEvent;
}
impl SingletonEntity for Prompt {}
impl PromptConfiguration {
/// The default Warp prompt, synthesized from legacy prompt settings.
/// The order of chips is important and would affect a lot of users if rearranged.
pub fn default_prompt() -> Self {
Self::default_prompt_with_pr_chip_suppressed(false)
}
pub fn default_prompt_with_pr_chip_suppressed(suppress_pr_chip: bool) -> Self {
use crate::features::FeatureFlag;
let mut chips = vec![
ContextChipKind::CondaEnvironment,
ContextChipKind::VirtualEnvironment,
ContextChipKind::Ssh,
ContextChipKind::Subshell,
ContextChipKind::NodeVersion,
ContextChipKind::WorkingDirectory,
ContextChipKind::ShellGitBranch,
ContextChipKind::GitDiffStats,
ContextChipKind::KubernetesContext,
];
if FeatureFlag::GithubPrPromptChip.is_enabled() && !suppress_pr_chip {
chips.push(ContextChipKind::GithubPullRequest);
}
Self::from_chips(chips, false, WarpPromptSeparator::None)
}
pub fn from_chips(
chips: impl IntoIterator<Item = ContextChipKind>,
same_line_prompt_enabled: bool,
separator: WarpPromptSeparator,
) -> Self {
Self {
chips: chips
.into_iter()
.map(|chip| PromptChip::new(chip, Default::default()))
.collect(),
did_separate_git_diff_stats: true,
same_line_prompt_enabled,
separator,
}
}
pub fn chip_kinds(&self) -> Vec<ContextChipKind> {
self.chips
.iter()
.map(|chip| chip.chip.clone())
.collect_vec()
}
/// Normalizes custom prompt configs after deserialization.
///
/// `ShellGitBranch` originally rendered both the branch selector and git diff stats.
/// To preserve the previous behavior, we insert `GitDiffStats` immediately after `ShellGitBranch`.
///
/// This is gated by `did_separate_git_diff_stats` so we do not re-insert `GitDiffStats` after a
/// user intentionally removes it and saves their custom prompt.
fn normalize_custom_prompt_config(mut self) -> Self {
if !self.did_separate_git_diff_stats {
let already_has_git_diff_stats = self
.chips
.iter()
.any(|chip| chip.chip == ContextChipKind::GitDiffStats);
if !already_has_git_diff_stats {
let shell_git_branch_index = self
.chips
.iter()
.position(|chip| chip.chip == ContextChipKind::ShellGitBranch);
if let Some(index) = shell_git_branch_index {
self.chips.insert(
index + 1,
PromptChip::new(ContextChipKind::GitDiffStats, Default::default()),
);
}
}
self.did_separate_git_diff_stats = true;
}
self
}
pub fn same_line_prompt_enabled(&self) -> bool {
self.same_line_prompt_enabled
}
pub fn separator(&self) -> WarpPromptSeparator {
self.separator
}
pub fn remove_chip(&mut self, chip: ContextChipKind) {
self.chips.retain(|c| c.chip != chip);
}
pub fn add_chip_to_end(&mut self, chip: ContextChipKind) {
self.chips.push(PromptChip::new(chip, Default::default()));
}
}
+99
View File
@@ -0,0 +1,99 @@
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use warpui::{AppContext, SingletonEntity};
use crate::context_chips::ContextChipKind;
use super::current_prompt::CurrentPrompt;
use super::prompt::Prompt;
use super::{chips_to_string, ChipResult, ChipValue};
use crate::settings::WarpPromptSeparator;
/// Struct that holds a point in time snapshot of a prompt (chips are no longer interactive)
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PromptSnapshot {
chips: Vec<ChipResult>,
same_line_prompt_enabled: bool,
/// The separator to use as a trailing character at the end of Warp prompt, if any.
separator: WarpPromptSeparator,
}
impl PromptSnapshot {
pub fn from_current_prompt(current_prompt: &CurrentPrompt, ctx: &AppContext) -> Self {
let prompt = Prompt::as_ref(ctx);
let current_prompt_snapshot = current_prompt.snapshot();
let current_prompt_on_click_snapshot = current_prompt.on_click_snapshot();
// Get base chip kinds from prompt configuration
let all_chip_kinds = prompt.chip_kinds();
// Re-sort current prompt snapshot so that it matches the order of elements in prompt
let chips = all_chip_kinds
.iter()
.map(|chip_kind| {
let value = current_prompt_snapshot
.get(chip_kind)
.cloned()
.unwrap_or_default();
let on_click_values = current_prompt_on_click_snapshot
.get(chip_kind)
.cloned()
.unwrap_or_default();
ChipResult {
kind: chip_kind.clone(),
value,
on_click_values,
}
})
.collect_vec();
log::debug!("Current prompt snapshot: {chips:?}");
Self {
chips,
same_line_prompt_enabled: current_prompt.same_line_prompt_enabled(),
separator: current_prompt.separator(),
}
}
pub fn from_chips(
chips: Vec<ChipResult>,
same_line_prompt_enabled: bool,
separator: WarpPromptSeparator,
) -> Self {
Self {
chips,
same_line_prompt_enabled,
separator,
}
}
/// The value of the given chip, in this snapshot.
pub fn chip_value(&self, chip: &ContextChipKind) -> Option<ChipValue> {
self.chips.iter().find_map(|chip_result| {
if chip_result.kind == *chip {
chip_result.value.clone()
} else {
None
}
})
}
pub(crate) fn chips(&self) -> &Vec<ChipResult> {
&self.chips
}
pub(super) fn same_line_prompt_enabled(&self) -> bool {
self.same_line_prompt_enabled
}
pub(super) fn separator(&self) -> WarpPromptSeparator {
self.separator
}
}
impl std::fmt::Display for PromptSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&chips_to_string(self.chips.clone().into_iter()))
}
}
+121
View File
@@ -0,0 +1,121 @@
use serde_json::Value;
use warpui::{App, SingletonEntity};
use super::Prompt;
use crate::auth::AuthStateProvider;
use crate::settings::WarpPromptSeparator;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
context_chips::{
prompt::{PromptConfiguration, PromptSelection},
ContextChipKind,
},
terminal::session_settings::SessionSettings,
};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
}
#[test]
// Legacy prompt configs do not have git diff stats, so it should be added after normalization.
// `did_separate_git_diff_stats` is set to `false`.
fn test_prompt_config_adds_git_diff_stats_for_legacy_config() {
let config = PromptConfiguration::from_chips(
[
ContextChipKind::WorkingDirectory,
ContextChipKind::ShellGitBranch,
],
false,
WarpPromptSeparator::None,
);
let mut serialized = serde_json::to_value(config).expect("serialize prompt config");
let Value::Object(ref mut map) = serialized else {
panic!("expected object");
};
map.remove("did_separate_git_diff_stats");
let legacy_config: PromptConfiguration =
serde_json::from_value(serialized).expect("deserialize legacy config");
let normalized = legacy_config.normalize_custom_prompt_config();
assert_eq!(
normalized.chip_kinds(),
vec![
ContextChipKind::WorkingDirectory,
ContextChipKind::ShellGitBranch,
ContextChipKind::GitDiffStats,
]
);
}
#[test]
// Ensure that prompt configs don't re-insert git diff stats if they were explicitly removed.
// `did_separate_git_diff_stats` is set to `true`.
fn test_prompt_config_after_nomalization() {
let config = PromptConfiguration::from_chips(
[ContextChipKind::ShellGitBranch],
false,
WarpPromptSeparator::None,
);
let normalized = config.normalize_custom_prompt_config();
assert_eq!(
normalized.chip_kinds(),
vec![ContextChipKind::ShellGitBranch]
);
}
#[test]
fn test_prompt_settings() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let session_settings = SessionSettings::handle(&app);
let default_prompt = PromptConfiguration::default_prompt();
let prompt = app.add_singleton_model(Prompt::new);
// First, the default prompt should be set.
let current_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
assert_eq!(current_prompt_chips, default_prompt.chip_kinds());
session_settings.read(&app, |settings, _| {
assert_eq!(settings.saved_prompt.to_owned(), PromptSelection::Default)
});
// Now, set a new prompt.
let new_chips = [ContextChipKind::Ssh, ContextChipKind::WorkingDirectory];
prompt.update(&mut app, |prompt, ctx| {
prompt
.update(new_chips.clone(), false, WarpPromptSeparator::None, ctx)
.expect("Saving prompt failed")
});
// The configuration should be updated both in-memory and in settings.
let new_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
assert_eq!(
new_prompt_chips,
vec![ContextChipKind::Ssh, ContextChipKind::WorkingDirectory]
);
session_settings.read(&app, |settings, _| {
assert_eq!(
settings.saved_prompt.to_owned(),
PromptConfiguration::from_chips(new_chips, false, WarpPromptSeparator::None).into()
);
});
// If we reset the prompt, settings are cleared.
prompt.update(&mut app, |prompt, ctx| {
prompt.reset(ctx).expect("Saving prompt failed");
});
let reset_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
assert_eq!(reset_prompt_chips, default_prompt.chip_kinds());
session_settings.read(&app, |settings, _| {
assert_eq!(settings.saved_prompt.to_owned(), PromptSelection::Default);
});
});
}
+184
View File
@@ -0,0 +1,184 @@
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::{
menu::{MenuItem, MenuItemFields},
settings::WarpPromptSeparator,
terminal::{
model::session::Sessions,
session_settings::{SessionSettings, ToolbarChipSelection},
view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction},
},
};
use super::{
current_prompt::CurrentPrompt, prompt_snapshot::PromptSnapshot, ChipResult, ChipValue,
ContextChipKind,
};
/// The type of warp prompt being used
#[derive(Clone)]
pub enum PromptType {
/// A warp prompt that refreshes chip values on its own. Typical for local sessions.
Dynamic { prompt: ModelHandle<CurrentPrompt> },
/// A warp prompt that does not change unless explicitly overwritten. Used for viewers of shared sessions.
Static { snapshot: PromptSnapshot },
}
impl PromptType {
pub fn new_dynamic_from_sessions(
sessions: ModelHandle<Sessions>,
ctx: &mut ModelContext<Self>,
) -> Self {
let current_prompt = ctx.add_model(|ctx| CurrentPrompt::new(sessions, ctx));
Self::new_dynamic(current_prompt, ctx)
}
pub fn new_dynamic(
current_prompt: ModelHandle<CurrentPrompt>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.observe(&current_prompt, |_, _, ctx| ctx.notify());
Self::Dynamic {
prompt: current_prompt,
}
}
pub fn new_static(
chips: Vec<ChipResult>,
same_line_prompt_enabled: bool,
separator: WarpPromptSeparator,
) -> Self {
PromptType::Static {
snapshot: PromptSnapshot::from_chips(chips, same_line_prompt_enabled, separator),
}
}
/// Returns menu items for copying parts of the prompt given a prompt snapshot.
pub fn copy_menu_items(
&self,
position: PromptPosition,
ctx: &AppContext,
) -> Vec<MenuItem<TerminalAction>> {
self.chips(ctx)
.into_iter()
.filter_map(|chip_result| {
if chip_result.value.is_some() && chip_result.kind.is_copyable() {
if let Some(chip) = chip_result.kind.to_chip() {
Some(
MenuItemFields::new(format!("Copy {}", chip.title()))
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyPrompt {
position,
part: PromptPart::ContextChip(chip_result.kind),
},
))
.into_item(),
)
} else {
log::error!("Missing definition for chip: {:?}", chip_result.kind);
None
}
} else {
None
}
})
.collect()
}
pub fn latest_chip_value(
&self,
chip_kind: &ContextChipKind,
ctx: &AppContext,
) -> Option<ChipValue> {
match self {
Self::Dynamic { prompt } => prompt.as_ref(ctx).latest_chip_value(chip_kind).cloned(),
Self::Static { snapshot } => snapshot.chip_value(chip_kind),
}
}
pub fn prompt_as_string(&self, ctx: &AppContext) -> String {
match self {
Self::Dynamic { prompt } => prompt.as_ref(ctx).prompt_as_string(ctx),
Self::Static { snapshot } => snapshot.to_string(),
}
}
pub fn snapshot(&self, ctx: &AppContext) -> PromptSnapshot {
match self {
Self::Dynamic { prompt } => {
PromptSnapshot::from_current_prompt(prompt.as_ref(ctx), ctx)
}
Self::Static { snapshot } => snapshot.clone(),
}
}
pub fn chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
self.snapshot(ctx).chips().clone()
}
pub fn agent_view_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
let chip_kinds = SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.all_chips();
self.resolve_chip_kinds(chip_kinds, ctx)
}
pub fn agent_view_left_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
let chip_kinds = SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.left_chips();
self.resolve_chip_kinds(chip_kinds, ctx)
}
pub fn agent_view_right_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
let chip_kinds = SessionSettings::as_ref(ctx)
.agent_footer_chip_selection
.right_chips();
self.resolve_chip_kinds(chip_kinds, ctx)
}
pub fn cli_agent_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
let chip_kinds = SessionSettings::as_ref(ctx)
.cli_agent_footer_chip_selection
.all_chips();
self.resolve_chip_kinds(chip_kinds, ctx)
}
fn resolve_chip_kinds(
&self,
chip_kinds: Vec<ContextChipKind>,
ctx: &AppContext,
) -> Vec<ChipResult> {
chip_kinds
.into_iter()
.filter_map(|chip_kind| match self {
Self::Dynamic { prompt } => prompt.as_ref(ctx).latest_chip_result(&chip_kind),
Self::Static { snapshot } => snapshot
.chips()
.iter()
.find(|chip_result| chip_result.kind() == &chip_kind)
.cloned(),
})
.collect()
}
/// Whether same line prompt is enabled for the Warp Prompt.
pub fn same_line_prompt_enabled(&self, ctx: &AppContext) -> bool {
match self {
Self::Dynamic { prompt } => prompt.as_ref(ctx).same_line_prompt_enabled(),
Self::Static { snapshot } => snapshot.same_line_prompt_enabled(),
}
}
/// The separator for the Warp prompt.
pub fn separator(&self, ctx: &AppContext) -> WarpPromptSeparator {
match self {
Self::Dynamic { prompt } => prompt.as_ref(ctx).separator(),
Self::Static { snapshot } => snapshot.separator(),
}
}
}
impl Entity for PromptType {
type Event = ();
}
+253
View File
@@ -0,0 +1,253 @@
//! The renderer for a single context chip.
use pathfinder_color::ColorU;
use warp_core::ui::theme::Fill;
use warpui::elements::{
ConstrainedBox, DraggableState, Hoverable, MouseStateHandle, OffsetPositioning, ParentElement,
ParentOffsetBounds, Stack,
};
use warpui::fonts::{Properties, Weight};
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponent;
use warpui::Action;
use warpui::{
elements::{Container, CrossAxisAlignment, Flex, Text},
Element,
};
use crate::appearance::Appearance;
use crate::ui_components::icons;
use super::context_chip::ContextChip;
use super::display_chip::{chip_container, udi_font_size};
use super::spacing;
use super::{ChipAvailability, ChipValue, ContextChipKind};
use pathfinder_geometry::vector::vec2f;
/// Styling consts.
const CORNER_RADIUS_PIXELS: f32 = 4.;
const ICON_MARGIN_RIGHT: f32 = 6.;
const LABEL_MARGIN_BOTTOM: f32 = 6.;
#[derive(Clone)]
pub struct RendererStyles {
pub value_color: ColorU,
pub font_properties: Properties,
}
impl RendererStyles {
pub fn new(value_color: ColorU, font_properties: Properties) -> Self {
Self {
value_color,
font_properties,
}
}
}
#[derive(Clone, Copy)]
pub enum ChipDragState {
Draggable { is_dragging: bool },
Undraggable,
}
#[derive(Clone)]
/// State for rendering a single context chip.
pub struct Renderer {
kind: ContextChipKind,
chip: ContextChip,
value: ChipValue,
styles: RendererStyles,
draggable_state: DraggableState,
tooltip_state_handle: MouseStateHandle,
remove_button_state_handle: MouseStateHandle,
is_disabled: bool,
tooltip_override_text: Option<String>,
}
impl Renderer {
pub fn new(
kind: ContextChipKind,
chip: ContextChip,
value: ChipValue,
styles: RendererStyles,
availability: ChipAvailability,
) -> Self {
let is_disabled = !availability.is_enabled();
let tooltip_override_text = availability.tooltip_override_text();
Self {
kind,
chip,
value,
styles,
draggable_state: Default::default(),
tooltip_state_handle: Default::default(),
remove_button_state_handle: Default::default(),
is_disabled,
tooltip_override_text,
}
}
pub fn default_from_kind(
chip_kind: ContextChipKind,
availability: ChipAvailability,
appearance: &Appearance,
) -> Option<Self> {
Self::default_from_kind_with_agent_view(chip_kind, availability, false, appearance)
}
pub fn default_from_kind_with_agent_view(
chip_kind: ContextChipKind,
availability: ChipAvailability,
is_in_agent_view: bool,
appearance: &Appearance,
) -> Option<Self> {
let chip = chip_kind.to_chip()?;
let placeholder_value = chip_kind.placeholder_value();
let styles = chip_kind.default_styles(appearance, is_in_agent_view);
Some(Self::new(
chip_kind,
chip,
placeholder_value,
styles,
availability,
))
}
pub fn draggable_state(&self) -> DraggableState {
self.draggable_state.clone()
}
pub fn chip_kind(&self) -> &ContextChipKind {
&self.kind
}
pub fn is_disabled(&self) -> bool {
self.is_disabled
}
fn render_remove_button<A: Action + Clone>(
&self,
action: A,
appearance: &Appearance,
) -> Box<dyn Element> {
let icon_size = appearance.monospace_font_size();
let button = Hoverable::new(self.remove_button_state_handle.clone(), |_| {
ConstrainedBox::new(
icons::Icon::X
.to_warpui_icon(appearance.theme().ui_error_color().into())
.finish(),
)
.with_height(icon_size)
.with_width(icon_size)
.finish()
});
button
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.with_cursor(Cursor::PointingHand)
.finish()
}
fn render_internal(
&self,
drag_state: ChipDragState,
remove_button: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut color = self.styles.value_color;
if self.is_disabled {
color.a = (color.a / 2).max(48);
}
let font_size = udi_font_size(appearance);
let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(icon) = self.kind.udi_icon() {
content.add_child(
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(color)).finish())
.with_height(font_size)
.with_width(font_size)
.finish(),
)
.with_margin_right(spacing::UDI_CHIP_ICON_GAP)
.finish(),
);
}
let text = Text::new_inline(
self.value.to_string(),
appearance.ui_font_family(),
font_size,
)
.with_color(color)
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish();
content.add_child(text);
if let Some(remove_button) = remove_button {
content.add_child(
Container::new(remove_button)
.with_margin_left(spacing::UDI_CHIP_ICON_GAP)
.finish(),
);
}
let container = chip_container(content.finish(), None, appearance);
let mut hoverable = Hoverable::new(self.tooltip_state_handle.clone(), |mouse_state| {
if !mouse_state.is_hovered()
|| matches!(drag_state, ChipDragState::Draggable { is_dragging: true })
{
return container.finish();
}
let tooltip = appearance.ui_builder().tool_tip(
self.tooltip_override_text
.clone()
.unwrap_or_else(|| self.chip.title().to_string()),
);
let mut stack = Stack::new();
stack.add_child(container.finish());
stack.add_positioned_overlay_child(
tooltip.build().finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., -2.5 * font_size),
ParentOffsetBounds::Unbounded,
warpui::elements::ParentAnchor::Center,
warpui::elements::ChildAnchor::Center,
),
);
stack.finish()
});
if matches!(drag_state, ChipDragState::Draggable { .. }) && !self.is_disabled {
hoverable = hoverable.with_cursor(Cursor::OpenHand);
}
hoverable.finish()
}
pub fn render_unused(
&self,
drag_state: ChipDragState,
appearance: &Appearance,
) -> Box<dyn Element> {
self.render_internal(drag_state, None, appearance)
}
pub fn render_used<A: Action + Clone>(
&self,
drag_state: ChipDragState,
on_remove_action: A,
appearance: &Appearance,
) -> Box<dyn Element> {
let remove_button =
(!self.is_disabled).then(|| self.render_remove_button(on_remove_action, appearance));
self.render_internal(drag_state, remove_button, appearance)
}
}
#[cfg(test)]
#[path = "renderer_test.rs"]
mod tests;
+59
View File
@@ -0,0 +1,59 @@
use pathfinder_color::ColorU;
use warpui::fonts::Properties;
use crate::context_chips::{ChipAvailability, ChipDisabledReason, ContextChipKind};
use super::{Renderer, RendererStyles};
#[test]
fn test_constructor_availability_updates_disabled_state_and_tooltip_override() {
let kind = ContextChipKind::ShellGitBranch;
let chip = kind.to_chip().expect("chip definition should exist");
let renderer = Renderer::new(
kind,
chip,
crate::context_chips::ChipValue::Text("main".to_string()),
RendererStyles::new(
ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
},
Properties::default(),
),
ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
command: "gh".to_string(),
}),
);
assert!(renderer.is_disabled);
assert_eq!(
renderer.tooltip_override_text.as_deref(),
Some("Requires the GitHub CLI")
);
}
#[test]
fn test_constructor_availability_enabled_has_no_disabled_state_or_tooltip_override() {
let kind = ContextChipKind::ShellGitBranch;
let chip = kind.to_chip().expect("chip definition should exist");
let renderer = Renderer::new(
kind,
chip,
crate::context_chips::ChipValue::Text("main".to_string()),
RendererStyles::new(
ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
},
Properties::default(),
),
ChipAvailability::Enabled,
);
assert!(!renderer.is_disabled);
assert_eq!(renderer.tooltip_override_text, None);
}
@@ -0,0 +1,17 @@
git rev-parse --is-inside-work-tree >/dev/null 2>/dev/null; or exit 0
git symbolic-ref --quiet --short HEAD >/dev/null 2>/dev/null; or exit 0
set remote_url (git remote get-url origin 2>/dev/null); or exit 0
string match -rq '^(git@github\.com:|https?://github\.com/|ssh://git@github\.com/)' -- $remote_url; or exit 0
set output (gh pr view --json url --jq .url 2>&1)
set exit_code $status
if test $exit_code -eq 0
printf '%s\n' "$output"
else
set joined_output (string join '\n' $output)
string match -rq 'no (open )?pull requests found for branch ' -- $joined_output; and exit 0
printf '%s\n' "$joined_output" >&2
exit $exit_code
end
@@ -0,0 +1,22 @@
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { exit 0 }
$branch = git symbolic-ref --quiet --short HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($branch)) { exit 0 }
$remoteUrl = git remote get-url origin 2>$null
if ($LASTEXITCODE -ne 0) { exit 0 }
if ($remoteUrl -notmatch '^(git@github\.com:|https?://github\.com/|ssh://git@github\.com/)') { exit 0 }
$output = gh pr view --json url --jq .url 2>&1 | Out-String
$exitCode = $LASTEXITCODE
$output = $output.TrimEnd()
if ($exitCode -eq 0) {
if (-not [string]::IsNullOrWhiteSpace($output)) { $output }
exit 0
}
if ($output -match 'no (open )?pull requests found for branch ') { exit 0 }
if (-not [string]::IsNullOrWhiteSpace($output)) { [Console]::Error.WriteLine($output) }
exit $exitCode
@@ -0,0 +1,26 @@
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
git symbolic-ref --quiet --short HEAD >/dev/null 2>&1 || exit 0
remote_url=$(git remote get-url origin 2>/dev/null) || exit 0
case "$remote_url" in
git@github.com:*|https://github.com/*|http://github.com/*|ssh://git@github.com/*)
;;
*)
exit 0
;;
esac
output=$(gh pr view --json url --jq .url 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
printf '%s\n' "$output"
else
case "$output" in
*'no pull requests found for branch '*|*'no open pull requests found for branch '*)
exit 0
;;
esac
printf '%s\n' "$output" >&2
exit $exit_code
fi
+20
View File
@@ -0,0 +1,20 @@
// Shared spacing constants for context chips and related UDI elements.
// Centralizing these values keeps spacing consistent across chips and makes
// it easy to adjust the overall rhythm in one place.
/// Vertical padding inside of a chip
pub const UDI_CHIP_VERTICAL_PADDING: f32 = 2.0;
/// Horizontal padding inside of a chip
pub const UDI_CHIP_HORIZONTAL_PADDING: f32 = 4.0;
/// Space between icon and label inside a chip
pub const UDI_CHIP_ICON_GAP: f32 = 4.0;
/// Consistent margins surrounding all chips
pub const UDI_CHIP_MARGIN: f32 = 8.0;
/// Spacing between rows when chips wrap
pub const UDI_ROW_RUN_SPACING: f32 = 8.0;
/// Top padding factor for universal developer input prompt - less top padding
pub const UDI_PROMPT_TOP_PADDING_FACTOR: f32 = 0.6;
/// Bottom padding factor for universal developer input prompt - more bottom padding
pub const UDI_PROMPT_BOTTOM_PADDING_FACTOR: f32 = 1.5;
/// Bottom padding for classic prompt attach images
pub const CLASSIC_PROMPT_ATTACH_IMAGES_BOTTOM_PADDING: f32 = 10.;