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
2 changes: 1 addition & 1 deletion app/src/ai/blocklist/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3074,7 +3074,7 @@ impl AIBlock {
ctx.emit(AIBlockEvent::OpenCodeInWarp {
source: CodeSource::Skill {
reference: reference.clone(),
location: LocalOrRemotePath::Local(path.clone()),
location: path.clone(),
origin: SkillOpenOrigin::EditFiles,
},
layout: *crate::util::file::external_editor::EditorSettings::as_ref(
Expand Down
37 changes: 24 additions & 13 deletions app/src/ai/blocklist/block/view_impl/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use pathfinder_geometry::vector::vec2f;
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;
Expand Down Expand Up @@ -487,12 +488,14 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
.collect_vec(),
};

let file_paths: Vec<_> = files.iter().map(|f| &f.name).collect();
let skill = common_path(&file_paths)
.and_then(|common| skill_path_from_file_path(&common))
.and_then(|skill_path| {
SkillManager::as_ref(app).skill_by_path(&skill_path)
});
let file_paths = files.iter().map(|file| {
PathBuf::from(shell_native_absolute_path(
&file.name,
props.shell_launch_data,
props.current_working_directory,
))
});
let skill = parsed_skill_for_common_local_paths(file_paths, app);
output_items.add_child(render_read_files(
props,
id,
Expand Down Expand Up @@ -1491,13 +1494,9 @@ fn render_search_codebase(
.render(app)
.finish()
} else {
let file_paths: Vec<_> =
files.iter().map(|f| &f.file_name).collect();
let skill = common_path(&file_paths)
.and_then(|common| skill_path_from_file_path(&common))
.and_then(|skill_path| {
SkillManager::as_ref(app).skill_by_path(&skill_path)
});
let file_paths =
files.iter().map(|file| PathBuf::from(&file.file_name));
let skill = parsed_skill_for_common_local_paths(file_paths, app);
let grouped = group_file_contexts_for_display(files, None, None);
return Some(render_read_files(
props,
Expand Down Expand Up @@ -1866,6 +1865,18 @@ fn render_read_files(
renderable_action.render(app).finish()
}

fn parsed_skill_for_common_local_paths(
file_paths: impl IntoIterator<Item = PathBuf>,
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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] skill_path_from_file_path returns a local PathBuf, but this wraps it in LocalOrRemotePath before calling skill_by_path; the existing lookup accepts a path key, so this call will not type-check. Keep this as skill_by_path(&skill_path) unless the manager API is changed in this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This helper still forces every detected skill path into LocalOrRemotePath::Local, so read/search results from a remote session will not match skills cached under remote paths. Thread the session's remote location/host into this lookup or suppress the button until a remote-aware location is available.

})
}

fn maybe_render_edit_document(
props: Props,
id: &AIAgentActionId,
Expand Down
75 changes: 43 additions & 32 deletions app/src/ai/blocklist/inline_action/code_diff_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use warp_core::HostId;
use warp_editor::content::buffer::InitialBufferState;
use warp_editor::render::element::VerticalExpansionBehavior;
use warp_util::file::FileSaveError;
use warp_util::path::common_path;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use warpui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
use warpui::elements::{
Expand Down Expand Up @@ -65,10 +66,9 @@ use crate::ai::mcp::{mcp_provider_from_file_path, MCPProvider};
use crate::ai::paths::host_native_absolute_path;
use crate::ai::predict::prompt_suggestions::ACCEPT_PROMPT_SUGGESTION_KEYBINDING;
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, SkillReference, SkillTelemetryEvent,
};
use crate::code::buffer_location::LocalOrRemotePath;
use crate::code::diff_viewer::{DiffViewer, DisplayMode};
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
use crate::code::editor::{add_color, remove_color};
Expand Down Expand Up @@ -248,7 +248,7 @@ pub enum CodeDiffViewEvent {
/// Emitted when the user opens a skill file from a code diff
OpenSkill {
reference: SkillReference,
path: PathBuf,
path: LocalOrRemotePath,
},
/// Emitted when the user opens an MCP config file from a code diff
OpenMCPConfig {
Expand Down Expand Up @@ -423,7 +423,7 @@ pub enum CodeDiffViewAction {
RevertChanges,
OpenSkill {
reference: SkillReference,
path: PathBuf,
path: LocalOrRemotePath,
mouse_state: MouseStateHandle,
},
OpenMCPConfig {
Expand Down Expand Up @@ -1572,30 +1572,32 @@ impl CodeDiffView {
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);

let file_paths: Vec<PathBuf> = self
let file_locations: Vec<LocalOrRemotePath> = self
.pending_diffs
.iter()
.filter_map(|diff| {
diff.diff_view
.as_ref(app)
.file_path()
.and_then(|p| p.to_local_path())
self.location_for_standardized_path(diff.diff_view.as_ref(app).file_path()?)
})
.collect();

// Renders the 'open skill' button if all edited files live in the same skill directory
let skill = common_path(&file_paths)
.and_then(|common| skill_path_from_file_path(&common))
.and_then(|skill_path| SkillManager::as_ref(app).skill_by_path(&skill_path));
if let Some((skill, skill_path)) = skill.and_then(|skill| {
skill
.path
.to_local_path()
.map(|path| (skill, path.to_path_buf()))
}) {
// Renders the 'open skill' button only if every edited file lives in the same skill directory.
let skill_paths = file_locations
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] filter_map drops files that are not inside a skill directory, so a diff containing one skill file plus unrelated files still passes the all(|path| path == first_path) check and renders the Open Skill button. Require every file_location to resolve to the same skill path before showing the button.

.iter()
.map(skill_path_from_location)
.collect::<Option<Vec<_>>>();
let skill = skill_paths.and_then(|skill_paths| {
let first_path = skill_paths.first()?;
skill_paths
.iter()
.all(|path| path == first_path)
.then(|| SkillManager::as_ref(app).skill_by_path(first_path))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] first_path is a LocalOrRemotePath, but skill_by_path looks up parsed skills by path, so this call will not type-check with the current manager API. Convert back to the manager's key type or update the manager API consistently in this PR.

.flatten()
});
if let Some(skill) = skill {
let skill_path = skill.path.clone();
let skill_reference = SkillManager::handle(app)
.as_ref(app)
.reference_for_skill_path(&skill.path);
.reference_for_skill_path(&skill_path);
let skill_button_handle = self.button_mouse_states.skill_button_handle.clone();

let skill_icon_override = icon_override_for_skill_name(&skill.name);
Expand Down Expand Up @@ -1623,7 +1625,12 @@ impl CodeDiffView {
// Renders the 'open config' button only when every MCP config file in this diff
// belongs to the same provider. Mixed-provider diffs (e.g. editing both a Claude
// config and a Warp config at once) show no badge to avoid misleading attribution.
let mcp_configs: Vec<_> = file_paths
// MCP config actions currently operate on local paths only.
let local_file_paths: Vec<PathBuf> = file_locations
.iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect();
let mcp_configs: Vec<_> = local_file_paths
.iter()
.filter_map(|path| {
mcp_provider_from_file_path(path).map(|provider| (provider, path.to_path_buf()))
Expand Down Expand Up @@ -2594,17 +2601,21 @@ impl CodeDiffView {
/// Returns the primary file location as a `LocalOrRemotePath`,
/// using `diff_session_type` to correctly identify remote files.
pub fn primary_file_location(&self, app: &AppContext) -> Option<LocalOrRemotePath> {
let path_str = self.primary_file_path(app)?;
self.pending_diffs
.first()?
.diff_view
.as_ref(app)
.file_path()
.and_then(|path| self.location_for_standardized_path(path))
}

fn location_for_standardized_path(&self, path: &StandardizedPath) -> Option<LocalOrRemotePath> {
match &self.diff_session_type {
DiffSessionType::Local => Some(LocalOrRemotePath::Local(PathBuf::from(path_str))),
DiffSessionType::Remote(host_id) => {
StandardizedPath::try_new(&path_str).ok().map(|path| {
LocalOrRemotePath::Remote(warp_util::remote_path::RemotePath {
host_id: host_id.clone(),
path,
})
})
}
DiffSessionType::Local => path.to_local_path().map(LocalOrRemotePath::Local),
DiffSessionType::Remote(host_id) => Some(LocalOrRemotePath::Remote(RemotePath {
host_id: host_id.clone(),
path: path.clone(),
})),
}
}
}
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_file_path, skill_path_from_location,
};
pub trait SkillPathQuery {
fn to_skill_location(&self) -> LocalOrRemotePath;
Expand Down
17 changes: 17 additions & 0 deletions app/src/ai/skills/skill_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ 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"));
}
}
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 {
Expand Down
9 changes: 3 additions & 6 deletions app/src/terminal/input/slash_command_model.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
use ai::skills::SkillReference;
use input_classifier::InputType;
use settings::Setting as _;
use std::path::PathBuf;
use warp_core::features::FeatureFlag;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};

use crate::ai::blocklist::{
Expand Down Expand Up @@ -250,14 +248,13 @@ impl SlashCommandModel {

let skill_name = possible_command.strip_prefix('/')?;

let cwd = self
let cwd_path = self
.active_session
.as_ref(ctx)
.current_working_directory()
.map(|cwd| LocalOrRemotePath::Local(PathBuf::from(cwd)));
.current_working_directory_location(ctx);
let skills = SkillManager::handle(ctx)
.as_ref(ctx)
.get_skills_for_working_directory(cwd.as_ref(), ctx);
.get_skills_for_working_directory(cwd_path.as_ref(), ctx);

let matched_skill = skills.into_iter().find(|skill| skill.name == skill_name)?;

Expand Down
8 changes: 3 additions & 5 deletions app/src/terminal/input/slash_commands/data_source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use warp_cli::agent::Harness;
use warp_core::features::FeatureFlag;
use warp_core::ui::appearance::Appearance;
use warp_core::ui::Icon as WarpIcon;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::fonts::FamilyId;
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
pub use zero_state::*;
Expand Down Expand Up @@ -505,14 +504,13 @@ impl SyncDataSource for SlashCommandDataSource {
// Skills are invoked by the agent, so they're hidden entirely when AI is globally off.
if FeatureFlag::ListSkills.is_enabled() && AISettings::as_ref(app).is_any_ai_enabled(app) {
let cli_agent_providers = self.active_cli_agent_providers(app);
let cwd = self
let cwd_path = self
.active_session
.as_ref(app)
.current_working_directory()
.map(|cwd| LocalOrRemotePath::Local(cwd.into()));
.current_working_directory_location(app);
let skills = SkillManager::handle(app)
.as_ref(app)
.get_skills_for_working_directory(cwd.as_ref(), app);
.get_skills_for_working_directory(cwd_path.as_ref(), app);

let skill_manager = SkillManager::as_ref(app);
for mut skill in skills {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use itertools::Itertools;
use warp_core::features::FeatureFlag;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{Entity, ModelHandle, SingletonEntity};

use crate::ai::skills::SkillManager;
Expand Down Expand Up @@ -109,8 +108,7 @@ impl SyncDataSource for ZeroStateDataSource {
let cwd = slash_command_data_source
.active_session_for_v2_zero_state()
.as_ref(app)
.current_working_directory()
.map(|cwd| LocalOrRemotePath::Local(cwd.into()));
.current_working_directory_location(app);
let skill_manager_handle = SkillManager::handle(app);
let skill_manager = skill_manager_handle.as_ref(app);
let skills = skill_manager.get_skills_for_working_directory(cwd.as_ref(), app);
Expand Down
Loading