Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/ai/blocklist/block/view_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,7 @@ impl View for AIBlock {
action_buttons: &self.action_buttons,
view_screenshot_buttons: &self.view_screenshot_buttons,
action_model: &self.action_model,
active_session: &self.active_session,
editor_views: &self.code_editor_views,
current_working_directory: self.current_working_directory.as_ref(),
shell_launch_data: self.shell_launch_data.as_ref(),
Expand Down
66 changes: 44 additions & 22 deletions app/src/ai/blocklist/block/view_impl/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::cell::OnceCell;
use std::cmp::Ordering;
use std::collections::HashMap;
#[allow(unused_imports)]
use std::path::{Component, Path, PathBuf};
use std::path::{Component, Path};
use std::rc::Rc;
use std::sync::Arc;

Expand All @@ -23,8 +23,6 @@ use ui_components::{button, Component as _, Options as _};
use warp_core::channel::ChannelState;
use warp_core::ui::theme::color::internal_colors;
use warp_util::local_or_remote_path::LocalOrRemotePath;
#[allow(unused_imports)]
use warp_util::path::{common_path, CleanPathResult};
use warpui::elements::new_scrollable::SingleAxisConfig;
use warpui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
Expand Down Expand Up @@ -103,13 +101,14 @@ use crate::ai::blocklist::view_util::format_credits;
use crate::ai::blocklist::{AIBlockResponseRating, BlocklistAIActionModel, SuggestionChipView};
use crate::ai::paths::shell_native_absolute_path;
use crate::ai::skills::{
icon_override_for_skill_name, render_skill_button, skill_path_from_file_path, SkillManager,
icon_override_for_skill_name, render_skill_button, skill_path_from_location, SkillManager,
SkillOpenOrigin,
};
use crate::appearance::Appearance;
use crate::code::diff_viewer::DisplayMode;
use crate::code::editor_management::CodeSource;
use crate::settings_view::SettingsSection;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::terminal::ShellLaunchData;
use crate::ui_components::blended_colors;
Expand All @@ -134,6 +133,7 @@ pub(crate) struct Props<'a> {
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
pub(super) view_screenshot_buttons: &'a HashMap<AIAgentActionId, ui_components::button::Button>,
pub(crate) action_model: &'a ModelHandle<BlocklistAIActionModel>,
pub(crate) active_session: &'a ModelHandle<ActiveSession>,
pub(super) editor_views: &'a [EmbeddedCodeEditorView],
pub(super) current_working_directory: Option<&'a String>,
pub(super) shell_launch_data: Option<&'a ShellLaunchData>,
Expand Down Expand Up @@ -488,14 +488,23 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
.collect_vec(),
};

let file_paths = files.iter().map(|file| {
PathBuf::from(shell_native_absolute_path(
&file.name,
props.shell_launch_data,
props.current_working_directory,
))
let file_locations = files
.iter()
.map(|file| {
let path = shell_native_absolute_path(
&file.name,
props.shell_launch_data,
props.current_working_directory,
);
props
.active_session
.as_ref(app)
.location_for_path(&path, app)
})
.collect::<Option<Vec<_>>>();
let skill = file_locations.and_then(|file_locations| {
parsed_skill_for_common_locations(file_locations, app)
});
let skill = parsed_skill_for_common_local_paths(file_paths, app);
output_items.add_child(render_read_files(
props,
id,
Expand Down Expand Up @@ -1494,9 +1503,18 @@ fn render_search_codebase(
.render(app)
.finish()
} else {
let file_paths =
files.iter().map(|file| PathBuf::from(&file.file_name));
let skill = parsed_skill_for_common_local_paths(file_paths, app);
let file_locations = files
.iter()
.map(|file| {
props
.active_session
.as_ref(app)
.location_for_path(&file.file_name, app)
})
.collect::<Option<Vec<_>>>();
let skill = file_locations.and_then(|file_locations| {
parsed_skill_for_common_locations(file_locations, app)
});
let grouped = group_file_contexts_for_display(files, None, None);
return Some(render_read_files(
props,
Expand Down Expand Up @@ -1865,16 +1883,20 @@ fn render_read_files(
renderable_action.render(app).finish()
}

fn parsed_skill_for_common_local_paths(
file_paths: impl IntoIterator<Item = PathBuf>,
fn parsed_skill_for_common_locations(
file_locations: impl IntoIterator<Item = LocalOrRemotePath>,
app: &AppContext,
) -> Option<&ai::skills::ParsedSkill> {
let file_paths = file_paths.into_iter().collect_vec();
common_path(&file_paths)
.and_then(|common| skill_path_from_file_path(&common))
.and_then(|skill_path| {
SkillManager::as_ref(app).skill_by_path(&LocalOrRemotePath::Local(skill_path))
})
let skill_paths = file_locations
.into_iter()
.map(|location| skill_path_from_location(&location))
.collect::<Option<Vec<_>>>()?;
let first_skill_path = skill_paths.first()?;
skill_paths
.iter()
.all(|skill_path| skill_path == first_skill_path)
.then(|| SkillManager::as_ref(app).skill_by_path(first_skill_path))
.flatten()
}

fn maybe_render_edit_document(
Expand Down
93 changes: 92 additions & 1 deletion app/src/ai/blocklist/block/view_impl/output_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
use ai::agent::action::UploadArtifactRequest;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::{DirectoryWatcher, RepoMetadataModel};
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::App;
use watcher::HomeDirectoryWatcher;

use super::format_upload_artifact_text;
use super::{format_upload_artifact_text, parsed_skill_for_common_locations};
use crate::ai::agent::UploadArtifactResult;
use crate::ai::skills::SkillManager;
use crate::settings::AISettings;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;

#[test]
fn format_upload_artifact_text_includes_request_details() {
Expand Down Expand Up @@ -62,3 +74,82 @@ fn format_upload_artifact_text_includes_terminal_status() {
format_upload_artifact_text(&request, Some(&UploadArtifactResult::Cancelled));
assert_eq!(cancelled_text, "Upload artifact: reports/daily.txt");
}

fn remote_location(host_id: &HostId, path: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
StandardizedPath::try_new(path).unwrap(),
))
}

#[test]
fn parsed_skill_for_common_locations_resolves_cached_remote_skill() {
let host_id = HostId::new("remote-host".to_string());
let skill = ParsedSkill {
name: "deploy".to_string(),
description: "Deploy skill".to_string(),
path: remote_location(&host_id, "/repo/.agents/skills/deploy/SKILL.md"),
content: "# Deploy".to_string(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Project,
};
let locations = vec![
remote_location(&host_id, "/repo/.agents/skills/deploy/README.md"),
remote_location(&host_id, "/repo/.agents/skills/deploy/scripts/run.sh"),
];

App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
let manager = app.add_singleton_model(SkillManager::new);
manager.update(&mut app, |manager, _| {
manager.add_skill_for_testing(skill.clone());
});

let resolved = manager.read(&app, |_, ctx| {
parsed_skill_for_common_locations(locations, ctx).map(|skill| skill.path.clone())
});
assert_eq!(resolved, Some(skill.path));
});
}

#[test]
fn parsed_skill_for_common_locations_does_not_mix_remote_hosts() {
let first_host = HostId::new("first-host".to_string());
let second_host = HostId::new("second-host".to_string());
let skill = ParsedSkill {
name: "deploy".to_string(),
description: "Deploy skill".to_string(),
path: remote_location(&first_host, "/repo/.agents/skills/deploy/SKILL.md"),
content: "# Deploy".to_string(),
line_range: None,
provider: SkillProvider::Agents,
scope: SkillScope::Project,
};
let locations = vec![
remote_location(&first_host, "/repo/.agents/skills/deploy/README.md"),
remote_location(&second_host, "/repo/.agents/skills/deploy/README.md"),
];

App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
let manager = app.add_singleton_model(SkillManager::new);
manager.update(&mut app, |manager, _| {
manager.add_skill_for_testing(skill);
});

assert!(manager.read(&app, |_, ctx| {
parsed_skill_for_common_locations(locations, ctx).is_none()
}));
});
}
30 changes: 4 additions & 26 deletions app/src/ai/skills/file_watchers/utils.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::path::{Path, PathBuf};

use ai::skills::{
home_skills_path, parse_skill, read_skills, ParsedSkill, SkillProvider,
SKILL_PROVIDER_DEFINITIONS,
home_skills_path, parse_skill, provider_parent_directory_for_skills_root, read_skills,
ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
};
use anyhow::Error;
use repo_metadata::local_model::GetContentsArgs;
Expand Down Expand Up @@ -224,30 +224,8 @@ pub fn extract_skill_parent_directory(
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
};

for definition in SKILL_PROVIDER_DEFINITIONS.iter() {
let mut parent_directory = skills_root.clone();
let mut matches_provider = true;
for component in definition.skills_path.components().rev() {
let Some(expected_component) = component.as_os_str().to_str() else {
matches_provider = false;
break;
};
if parent_directory.file_name() != Some(expected_component) {
matches_provider = false;
break;
}
let Some(parent) = parent_directory.parent() else {
matches_provider = false;
break;
};
parent_directory = parent;
}
if matches_provider {
return Ok(parent_directory);
}
}

Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()))
provider_parent_directory_for_skills_root(&skills_root)
.ok_or_else(|| anyhow::anyhow!("Not a skill path: {}", path.display_path()))
}

/// Check if this path is a skill directory under a home directory provider path
Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/skills/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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,
skill_path_from_file_path, skill_path_from_location,
skill_path_from_location,
};
pub trait SkillPathQuery {
fn to_skill_location(&self) -> LocalOrRemotePath;
Expand Down
51 changes: 7 additions & 44 deletions app/src/ai/skills/skill_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

use ai::skills::{
home_skills_path, provider_rank, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
provider_parent_directory_for_skills_root, provider_rank, ParsedSkill, SkillProvider,
};
use lazy_static::lazy_static;
use siphasher::sip::SipHasher;
Expand All @@ -21,7 +20,6 @@ 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;
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;

lazy_static! {
static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
Expand Down Expand Up @@ -167,53 +165,18 @@ pub fn icon_override_for_skill_name(name: &str) -> Option<Icon> {
pub fn skill_path_from_location(location: &LocalOrRemotePath) -> Option<LocalOrRemotePath> {
let mut current = Some(location.clone());
while let Some(candidate_skill_dir) = current {
if let Some(provider_dir) = candidate_skill_dir.parent() {
if SKILL_PROVIDER_DEFINITIONS.iter().any(|definition| {
provider_dir
.path_component()
.ends_with(&definition.skills_path.to_string_lossy())
}) {
return Some(candidate_skill_dir.join("SKILL.md"));
}
if candidate_skill_dir
.parent()
.and_then(|provider_dir| provider_parent_directory_for_skills_root(&provider_dir))
.is_some()
{
return Some(candidate_skill_dir.join("SKILL.md"));
}
current = candidate_skill_dir.parent();
}
None
}

pub fn skill_path_from_file_path(file_path: &Path) -> Option<PathBuf> {
for definition in SKILL_PROVIDER_DEFINITIONS.iter() {
let home_skill_dirs = if definition.provider == SkillProvider::Warp {
warp_managed_skill_dirs()
} else {
home_skills_path(definition.provider).into_iter().collect()
};
for home_skills_path in home_skill_dirs {
if let Ok(relative_path) = file_path.strip_prefix(&home_skills_path) {
let skill_name = relative_path.components().next()?;
return Some(home_skills_path.join(skill_name).join("SKILL.md"));
}
}
}
let path_components: Vec<_> = file_path.components().collect();

for def in SKILL_PROVIDER_DEFINITIONS.iter() {
let skill_components: Vec<_> = def.skills_path.components().collect();

for (idx, window) in path_components.windows(skill_components.len()).enumerate() {
if window == skill_components.as_slice() {
let skill_dir = PathBuf::from_iter(
file_path
.components()
.take(idx + skill_components.len() + 1),
);
return Some(skill_dir.join("SKILL.md"));
}
}
}
None
}

#[cfg(test)]
#[path = "skill_utils_tests.rs"]
mod tests;
Loading
Loading