Complete agent monitoring and Galaxy Control integration
- expose command-monitor conversations and preserve visible agent transcripts - add bounded polling and a dedicated shell interrupt tool - improve direct-provider images, skills, tool history, and usage handling - package and brand Galaxy Control across releases, installers, persistence, and docs
This commit is contained in:
@@ -22,7 +22,7 @@ use crate::settings::user_preferences_toml_file_path;
|
||||
pub enum BundledSkillActivation {
|
||||
/// Always active.
|
||||
Always,
|
||||
/// Active only when a specific Warp feature is enabled.
|
||||
/// Active only when a specific Galaxy feature is enabled.
|
||||
RequiresFeature(FeatureFlag),
|
||||
/// Active only when a specific MCP server is running.
|
||||
RequiresMcp(McpIntegration),
|
||||
@@ -170,14 +170,14 @@ struct BundledSkillDefinition {
|
||||
icon: Icon,
|
||||
}
|
||||
|
||||
/// Skills bundled with Warp for a single host.
|
||||
/// Skills bundled with Galaxy for a single host.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BundledSkill {
|
||||
definitions: HashMap<String, BundledSkillDefinition>,
|
||||
}
|
||||
|
||||
impl BundledSkill {
|
||||
/// Detect all skill definitions bundled with Warp for the local host.
|
||||
/// Detect all skill definitions bundled with Galaxy for the local host.
|
||||
pub async fn detect() -> Self {
|
||||
let Some(resources_dir) = galaxy_core::paths::bundled_resources_dir() else {
|
||||
return Self::default();
|
||||
@@ -336,7 +336,7 @@ impl BundledSkill {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load skill definitions bundled with Warp.
|
||||
/// Load skill definitions bundled with Galaxy.
|
||||
async fn load_bundled_skill_definitions(
|
||||
resources_dir: &Path,
|
||||
) -> HashMap<String, BundledSkillDefinition> {
|
||||
@@ -439,11 +439,8 @@ pub(crate) async fn read_bundled_skills(
|
||||
/// Builds the context map for bundled skill variable substitution.
|
||||
///
|
||||
/// Supported variables:
|
||||
/// - `{{warp_server_url}}` - The server root URL (e.g., `https://api.warp.dev`)
|
||||
/// - `{{warp_cli_binary_name}}` - The CLI binary name (e.g., `warp` or `warp-cli`)
|
||||
/// - `{{warpctrl_binary_name}}` - The channel-specific Warp Control command name
|
||||
/// - `{{warpctrl_wrapper_path}}` - Path to the bundled Warp Control wrapper
|
||||
/// - `{{warp_url_scheme}}` - The URL scheme (e.g., `warp`, `warpdev`, `warppreview`)
|
||||
/// - `{{galaxyctrl_binary_name}}` - The channel-specific Galaxy Control command name
|
||||
/// - `{{galaxyctrl_wrapper_path}}` - Path to the bundled Galaxy Control wrapper
|
||||
/// - `{{settings_schema_path}}` - Path to the bundled JSON settings schema
|
||||
/// - `{{skill_dir}}` - Path to the bundled skill's directory
|
||||
/// - `{{settings_file_path}}` - Path to the user's settings TOML file
|
||||
@@ -454,29 +451,17 @@ pub(crate) fn build_bundled_skill_context(
|
||||
) -> HashMap<String, String> {
|
||||
[
|
||||
(
|
||||
"warp_server_url".to_owned(),
|
||||
ChannelState::server_root_url().into_owned(),
|
||||
"galaxyctrl_binary_name".to_owned(),
|
||||
ChannelState::channel().galaxyctrl_command_name().to_owned(),
|
||||
),
|
||||
(
|
||||
"warp_cli_binary_name".to_owned(),
|
||||
ChannelState::channel().cli_command_name().to_owned(),
|
||||
),
|
||||
(
|
||||
"warpctrl_binary_name".to_owned(),
|
||||
ChannelState::channel().warpctrl_command_name().to_owned(),
|
||||
),
|
||||
(
|
||||
"warpctrl_wrapper_path".to_owned(),
|
||||
"galaxyctrl_wrapper_path".to_owned(),
|
||||
resources_dir
|
||||
.join("bin")
|
||||
.join(ChannelState::channel().warpctrl_command_name())
|
||||
.join(ChannelState::channel().galaxyctrl_command_name())
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"warp_url_scheme".to_owned(),
|
||||
ChannelState::url_scheme().to_owned(),
|
||||
),
|
||||
(
|
||||
"settings_file_path".to_owned(),
|
||||
user_preferences_toml_file_path().display().to_string(),
|
||||
@@ -500,11 +485,11 @@ pub(crate) fn build_bundled_skill_context(
|
||||
|
||||
/// Returns the icon for a bundled skill, given its directory-based ID.
|
||||
/// Skills with a known brand (e.g. `pr-comments` → GitHub) get a
|
||||
/// branded icon; everything else falls back to the Warp logo.
|
||||
/// branded icon; everything else falls back to the Galaxy logo.
|
||||
pub(crate) fn icon_for_bundled_skill(skill_id: &str) -> Icon {
|
||||
match skill_id {
|
||||
"pr-comments" => Icon::Github,
|
||||
_ => Icon::WarpLogoLight,
|
||||
_ => Icon::GalaxyLogo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +505,7 @@ pub(crate) fn activation_for_bundled_skill(
|
||||
"modify-settings" => {
|
||||
BundledSkillActivation::RequiresFile(resources_dir.join("settings_schema.json"))
|
||||
}
|
||||
"warpctrl" => BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
|
||||
"galaxyctrl" => BundledSkillActivation::RequiresFeature(FeatureFlag::GalaxyControlCli),
|
||||
_ => BundledSkillActivation::Always,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ fn remote_content<'a>(bundled_skills: &'a BundledSkills, host_id: &HostId) -> Op
|
||||
.map(|skill| skill.content.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_skill_icons_use_galaxy_brand_by_default() {
|
||||
assert_eq!(icon_for_bundled_skill("galaxyctrl"), Icon::GalaxyLogo);
|
||||
assert_eq!(icon_for_bundled_skill("pr-comments"), Icon::Github);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_and_remote_catalogs_are_isolated() {
|
||||
let first_host_id = HostId::new("first-host".to_string());
|
||||
|
||||
@@ -60,7 +60,7 @@ pub use listed_skill::SkillDescriptor;
|
||||
|
||||
mod skill_utils;
|
||||
pub use skill_utils::{
|
||||
icon_override_for_skill_name, list_skills_if_changed, render_skill_button,
|
||||
icon_override_for_skill_name, list_skills_for_request, render_skill_button,
|
||||
skill_path_from_location,
|
||||
};
|
||||
pub trait SkillPathQuery {
|
||||
|
||||
@@ -533,7 +533,7 @@ fn test_read_bundled_skills_with_variable_substitution() {
|
||||
let resources_dir = temp_dir.path();
|
||||
let skills_dir = resources_dir.join("bundled/skills");
|
||||
|
||||
// Create a test skill with variables
|
||||
// Create a test skill with local variables.
|
||||
let skill_dir = skills_dir.join("test-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
@@ -544,8 +544,8 @@ name: test-skill
|
||||
description: Test skill with variables
|
||||
---
|
||||
|
||||
Run `{{galaxy_cli_binary_name}}` to connect to {{warp_server_url}}.
|
||||
Use `{{warpctrl_binary_name}}` from {{warpctrl_wrapper_path}}.
|
||||
Use `{{galaxyctrl_binary_name}}` from {{galaxyctrl_wrapper_path}}.
|
||||
Read {{settings_schema_path}} when validating settings.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -555,17 +555,16 @@ Use `{{warpctrl_binary_name}}` from {{warpctrl_wrapper_path}}.
|
||||
assert_eq!(skills.len(), 1);
|
||||
let skill = skills.get("test-skill").unwrap();
|
||||
|
||||
let expected_cli = ChannelState::channel().cli_command_name();
|
||||
let expected_url = ChannelState::server_root_url();
|
||||
let expected_galaxyctrl = ChannelState::channel().galaxyctrl_command_name();
|
||||
let expected_wrapper = resources_dir.join("bin").join(expected_galaxyctrl);
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Run `{expected_cli}` to connect to {expected_url}."
|
||||
)));
|
||||
let expected_warpctrl = ChannelState::channel().warpctrl_command_name();
|
||||
let expected_wrapper = resources_dir.join("bin").join(expected_warpctrl);
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Use `{expected_warpctrl}` from {}.",
|
||||
"Use `{expected_galaxyctrl}` from {}.",
|
||||
expected_wrapper.display()
|
||||
)));
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Read {} when validating settings.",
|
||||
resources_dir.join("settings_schema.json").display()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -611,7 +610,7 @@ fn test_read_bundled_skills_preserves_other_content() {
|
||||
let resources_dir = temp_dir.path();
|
||||
let skills_dir = resources_dir.join("bundled/skills");
|
||||
|
||||
// Create a test skill with both warp and non-warp variables
|
||||
// Create a test skill with both known and unknown variables.
|
||||
let skill_dir = skills_dir.join("test-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
@@ -622,7 +621,7 @@ name: test-skill
|
||||
description: Test skill with mixed variables
|
||||
---
|
||||
|
||||
Use {{other_var}}, {{galaxy_cli_binary_name}}, and {{skill_dir}} together.
|
||||
Use {{other_var}}, {{galaxyctrl_binary_name}}, and {{skill_dir}} together.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -632,9 +631,9 @@ Use {{other_var}}, {{galaxy_cli_binary_name}}, and {{skill_dir}} together.
|
||||
assert_eq!(skills.len(), 1);
|
||||
let skill = skills.get("test-skill").unwrap();
|
||||
|
||||
let expected_cli = ChannelState::channel().cli_command_name();
|
||||
let expected_control = ChannelState::channel().galaxyctrl_command_name();
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Use {{{{other_var}}}}, {expected_cli}, and {} together.",
|
||||
"Use {{{{other_var}}}}, {expected_control}, and {} together.",
|
||||
skill_dir.display()
|
||||
)));
|
||||
}
|
||||
@@ -675,14 +674,14 @@ fn test_build_bundled_skill_context() {
|
||||
let skill_dir = resources_dir.join("bundled/skills/test-skill");
|
||||
let context = build_bundled_skill_context(resources_dir, &skill_dir);
|
||||
|
||||
assert_eq!(context.len(), 9);
|
||||
assert!(context.contains_key("warp_server_url"));
|
||||
assert!(context.contains_key("galaxy_cli_binary_name"));
|
||||
assert!(context.contains_key("warpctrl_binary_name"));
|
||||
assert!(context.contains_key("warpctrl_wrapper_path"));
|
||||
assert!(context.contains_key("warp_url_scheme"));
|
||||
assert_eq!(context.len(), 6);
|
||||
assert!(context.contains_key("galaxyctrl_binary_name"));
|
||||
assert!(context.contains_key("galaxyctrl_wrapper_path"));
|
||||
assert!(context.contains_key("settings_file_path"));
|
||||
assert!(context.contains_key("keybindings_file_path"));
|
||||
assert!(!context.contains_key("warp_server_url"));
|
||||
assert!(!context.contains_key("warp_cli_binary_name"));
|
||||
assert!(!context.contains_key("warp_url_scheme"));
|
||||
assert_eq!(
|
||||
context.get("settings_schema_path").unwrap(),
|
||||
&resources_dir
|
||||
@@ -696,29 +695,17 @@ fn test_build_bundled_skill_context() {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
context.get("warp_server_url").unwrap(),
|
||||
&ChannelState::server_root_url().to_string()
|
||||
context.get("galaxyctrl_binary_name").unwrap(),
|
||||
ChannelState::channel().galaxyctrl_command_name()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("galaxy_cli_binary_name").unwrap(),
|
||||
ChannelState::channel().cli_command_name()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("warpctrl_binary_name").unwrap(),
|
||||
ChannelState::channel().warpctrl_command_name()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("warpctrl_wrapper_path").unwrap(),
|
||||
context.get("galaxyctrl_wrapper_path").unwrap(),
|
||||
&resources_dir
|
||||
.join("bin")
|
||||
.join(ChannelState::channel().warpctrl_command_name())
|
||||
.join(ChannelState::channel().galaxyctrl_command_name())
|
||||
.display()
|
||||
.to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("warp_url_scheme").unwrap(),
|
||||
ChannelState::url_scheme()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("settings_file_path").unwrap(),
|
||||
&crate::settings::user_preferences_toml_file_path()
|
||||
@@ -1024,13 +1011,13 @@ fn feature_gated_bundled_skill_is_listed_only_when_enabled() {
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let handle = app.add_singleton_model(SkillManager::new);
|
||||
let bundled_skills_guard = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
let warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let galaxy_control_cli = FeatureFlag::GalaxyControlCli.override_enabled(false);
|
||||
|
||||
handle.update(&mut app, |manager, _| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"warpctrl",
|
||||
bundled_test_skill("warpctrl", "Control Warp"),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
|
||||
"galaxyctrl",
|
||||
bundled_test_skill("galaxyctrl", "Control Galaxy"),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::GalaxyControlCli),
|
||||
);
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"always",
|
||||
@@ -1046,11 +1033,11 @@ fn feature_gated_bundled_skill_is_listed_only_when_enabled() {
|
||||
.map(|skill| skill.name)
|
||||
.collect::<HashSet<_>>()
|
||||
});
|
||||
assert!(!disabled_names.contains("warpctrl"));
|
||||
assert!(!disabled_names.contains("galaxyctrl"));
|
||||
assert!(disabled_names.contains("always"));
|
||||
|
||||
drop(warp_control_cli);
|
||||
let warp_control_cli_enabled = FeatureFlag::WarpControlCli.override_enabled(true);
|
||||
drop(galaxy_control_cli);
|
||||
let galaxy_control_cli_enabled = FeatureFlag::GalaxyControlCli.override_enabled(true);
|
||||
let enabled_names = handle.read(&app, |manager, ctx| {
|
||||
manager
|
||||
.get_skills_for_working_directory(None, ctx)
|
||||
@@ -1058,36 +1045,36 @@ fn feature_gated_bundled_skill_is_listed_only_when_enabled() {
|
||||
.map(|skill| skill.name)
|
||||
.collect::<HashSet<_>>()
|
||||
});
|
||||
assert!(enabled_names.contains("warpctrl"));
|
||||
assert!(enabled_names.contains("galaxyctrl"));
|
||||
assert!(enabled_names.contains("always"));
|
||||
drop(warp_control_cli_enabled);
|
||||
drop(galaxy_control_cli_enabled);
|
||||
drop(bundled_skills_guard);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_control_bundled_skill_activations_track_warp_control_feature() {
|
||||
fn galaxy_control_bundled_skill_activations_track_galaxy_control_feature() {
|
||||
App::test((), |app| async move {
|
||||
let settings = app.add_singleton_model(AISettings::new_with_defaults);
|
||||
let warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let activations = ["warpctrl"]
|
||||
let galaxy_control_cli = FeatureFlag::GalaxyControlCli.override_enabled(false);
|
||||
let activations = ["galaxyctrl"]
|
||||
.map(|skill_id| activation_for_bundled_skill(skill_id, Path::new("/resources")));
|
||||
for activation in &activations {
|
||||
assert!(!settings.read(&app, |_, ctx| activation.is_enabled(ctx)));
|
||||
}
|
||||
|
||||
drop(warp_control_cli);
|
||||
let warp_control_cli_enabled = FeatureFlag::WarpControlCli.override_enabled(true);
|
||||
drop(galaxy_control_cli);
|
||||
let galaxy_control_cli_enabled = FeatureFlag::GalaxyControlCli.override_enabled(true);
|
||||
for activation in &activations {
|
||||
assert!(settings.read(&app, |_, ctx| activation.is_enabled(ctx)));
|
||||
}
|
||||
drop(warp_control_cli_enabled);
|
||||
drop(galaxy_control_cli_enabled);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_control_direct_read_respects_warp_control_feature() {
|
||||
let reference = SkillReference::BundledSkillId("warpctrl".to_owned());
|
||||
fn galaxy_control_direct_read_respects_galaxy_control_feature() {
|
||||
let reference = SkillReference::BundledSkillId("galaxyctrl".to_owned());
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
@@ -1097,13 +1084,13 @@ fn warp_control_direct_read_respects_warp_control_feature() {
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let handle = app.add_singleton_model(SkillManager::new);
|
||||
let warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let galaxy_control_cli = FeatureFlag::GalaxyControlCli.override_enabled(false);
|
||||
|
||||
handle.update(&mut app, |manager, _| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"warpctrl",
|
||||
bundled_test_skill("warpctrl", "Control Warp"),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
|
||||
"galaxyctrl",
|
||||
bundled_test_skill("galaxyctrl", "Control Galaxy"),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::GalaxyControlCli),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1114,12 +1101,12 @@ fn warp_control_direct_read_respects_warp_control_feature() {
|
||||
.active_skill_by_reference(&reference, ctx)
|
||||
.is_none()));
|
||||
|
||||
drop(warp_control_cli);
|
||||
let warp_control_cli_enabled = FeatureFlag::WarpControlCli.override_enabled(true);
|
||||
drop(galaxy_control_cli);
|
||||
let galaxy_control_cli_enabled = FeatureFlag::GalaxyControlCli.override_enabled(true);
|
||||
assert!(handle.read(&app, |manager, ctx| manager
|
||||
.active_skill_by_reference(&reference, ctx)
|
||||
.is_some()));
|
||||
drop(warp_control_cli_enabled);
|
||||
drop(galaxy_control_cli_enabled);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Utility functions for working with skills.
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use ai::skills::{
|
||||
@@ -18,9 +18,7 @@ use warpui::prelude::MouseStateHandle;
|
||||
use warpui::{AppContext, Element, EventContext, SingletonEntity};
|
||||
|
||||
use super::{SkillDescriptor, SkillManager};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::view_util::render_provider_icon_button;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
|
||||
@@ -110,45 +108,21 @@ pub(crate) fn unique_skills(
|
||||
deduplicator.into_descriptors()
|
||||
}
|
||||
|
||||
/// Returns the list of skills if they have changed since the last time we sent them to the server.
|
||||
/// Skills are always included except when the current list matches the last list sent.
|
||||
pub fn list_skills_if_changed(
|
||||
/// Returns the current skill catalog for a model request.
|
||||
///
|
||||
/// Direct Bedrock and LiteLLM requests rebuild their system prompt on every
|
||||
/// turn, so the complete catalog must be present on every request rather than
|
||||
/// relying on hosted-server delta state.
|
||||
pub fn list_skills_for_request(
|
||||
working_directory: Option<&LocalOrRemotePath>,
|
||||
path_origin: &SkillPathOrigin,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
app: &AppContext,
|
||||
) -> Option<Vec<SkillDescriptor>> {
|
||||
let current_skills = SkillManager::as_ref(app).get_skills_for_working_directory_with_origin(
|
||||
) -> Vec<SkillDescriptor> {
|
||||
SkillManager::as_ref(app).get_skills_for_working_directory_with_origin(
|
||||
working_directory,
|
||||
path_origin,
|
||||
app,
|
||||
);
|
||||
|
||||
let previous_skills: Option<Vec<SkillDescriptor>> =
|
||||
conversation_id.and_then(|conversation_id| {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
history_model
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|conversation| conversation.latest_skills())
|
||||
});
|
||||
|
||||
// If there are no previous skills, we consider the skills changed and push the current skills to the context
|
||||
let skills_changed = previous_skills
|
||||
.map(|previous_skills| {
|
||||
let previous_skills_set: HashSet<SkillDescriptor> =
|
||||
HashSet::from_iter(previous_skills.iter().cloned());
|
||||
let current_skills_set: HashSet<SkillDescriptor> =
|
||||
HashSet::from_iter(current_skills.iter().cloned());
|
||||
|
||||
previous_skills_set != current_skills_set
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if skills_changed {
|
||||
Some(current_skills)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders an 'open skill' button for blocklist AI actions and the code diff view.
|
||||
|
||||
Reference in New Issue
Block a user