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
4 changes: 1 addition & 3 deletions src/agent/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -822,9 +822,7 @@ mod tests {
assert!(system
.content
.contains("<description>Use test-workflow for focused testing.</description>"));
assert!(system
.content
.contains("<location>file:///skills/test-workflow/SKILL.md</location>"));
assert!(!system.content.contains(skill.location.to_str().unwrap()));
assert!(system
.content
.contains("use the skill tool to load it before responding"));
Expand Down
238 changes: 175 additions & 63 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5426,64 +5426,7 @@ impl App {
let Some(popup) = self.file_actions.take() else {
return;
};
if action == FileAction::Copy {
self.copy_text_with_toast(&popup.copy_payload(), "Copied absolute path");
return;
}
let target = popup.target;
if action == FileAction::Reveal {
// push_toast uses the global Mutex<ToastManager>, so the
// worker can report its outcome without borrowing the app.
std::thread::spawn(move || {
match crate::utils::file_opener::reveal_file_path(&target.path) {
Ok(()) => push_toast(Toast::new(
format!("Revealed {}", target.path.display()),
ToastLevel::Info,
None,
)),
Err(err) => push_toast(Toast::new(
format!("File action failed: {err}"),
ToastLevel::Error,
None,
)),
}
});
return;
}
let result = match action {
FileAction::Open => {
let result = if let Some(line) = target.line {
crate::utils::file_opener::open_file_path_at_location(
&target.path,
line,
target.column.unwrap_or(1),
&self.editor,
)
} else {
crate::utils::file_opener::open_file_path(&target.path, &self.editor)
};
result.map(|outcome| {
if let crate::utils::file_opener::OpenOutcome::Suspend(command) =
outcome
{
self.pending_editor_suspend = Some(command);
}
})
}
FileAction::Reveal | FileAction::Copy => unreachable!(),
};
match result {
Ok(()) => push_toast(Toast::new(
format!("Opened {}", target.path.display()),
ToastLevel::Info,
None,
)),
Err(err) => push_toast(Toast::new(
format!("File action failed: {err}"),
ToastLevel::Error,
None,
)),
}
self.perform_file_action(action, popup.target);
}
}
}
Expand Down Expand Up @@ -5633,7 +5576,9 @@ impl App {
OverlayFocus::SessionsDialog if self.sessions_dialog_state.item_menu.is_none() => {
Some(&self.sessions_dialog_state.dialog)
}
OverlayFocus::SkillsDialog => Some(&self.skills_dialog_state.dialog),
OverlayFocus::SkillsDialog if !self.skills_dialog_state.info_is_open() => {
Some(&self.skills_dialog_state.dialog)
}
OverlayFocus::McpDialog => Some(&self.mcp_dialog_state.dialog),
OverlayFocus::TimelineDialog => Some(&self.timeline_dialog_state.dialog),
OverlayFocus::CommandPalette => Some(&self.command_palette_state.dialog),
Expand Down Expand Up @@ -6400,7 +6345,7 @@ impl App {
.join(""),
);
}
(_, OverlayFocus::SkillsDialog) => {
(_, OverlayFocus::SkillsDialog) if !self.skills_dialog_state.info_is_open() => {
self.skills_dialog_state
.dialog
.search_textarea
Expand Down Expand Up @@ -9293,13 +9238,91 @@ impl App {
self.overlay_focus = OverlayFocus::SkillsDialog;
}

fn perform_file_action(&mut self, action: FileAction, target: FileHyperlinkTarget) {
if action == FileAction::Copy {
match crate::utils::file_opener::absolute_file_path(&target.path) {
Ok(path) => {
self.copy_text_with_toast(&path.to_string_lossy(), "Copied absolute path")
}
Err(err) => push_toast(Toast::new(
format!("Failed to resolve file: {err}"),
ToastLevel::Error,
None,
)),
}
return;
}
if action == FileAction::Reveal {
// push_toast uses the global Mutex<ToastManager>, so the
// worker can report its outcome without borrowing the app.
std::thread::spawn(move || {
match crate::utils::file_opener::reveal_file_path(&target.path) {
Ok(()) => push_toast(Toast::new(
format!("Revealed {}", target.path.display()),
ToastLevel::Info,
None,
)),
Err(err) => push_toast(Toast::new(
format!("File action failed: {err}"),
ToastLevel::Error,
None,
)),
}
});
return;
}
let result = if let Some(line) = target.line {
crate::utils::file_opener::open_file_path_at_location(
&target.path,
line,
target.column.unwrap_or(1),
&self.editor,
)
} else {
crate::utils::file_opener::open_file_path(&target.path, &self.editor)
};
let result = result.map(|outcome| {
if let crate::utils::file_opener::OpenOutcome::Suspend(command) = outcome {
self.pending_editor_suspend = Some(command);
}
});
match result {
Ok(()) => push_toast(Toast::new(
format!("Opened {}", target.path.display()),
ToastLevel::Info,
None,
)),
Err(err) => push_toast(Toast::new(
format!("File action failed: {err}"),
ToastLevel::Error,
None,
)),
}
}

fn handle_skills_dialog_action(
&mut self,
action: crate::views::skills_dialog::SkillsDialogAction,
) {
let crate::views::skills_dialog::SkillsDialogAction::Toggle { skill_id } = action else {
return;
};
use crate::views::skills_dialog::SkillsDialogAction;
match action {
SkillsDialogAction::None => {}
SkillsDialogAction::InsertMention { name } => {
self.input.insert_str(&format!("@{name}"));
}
SkillsDialogAction::File { action, path } => self.perform_file_action(
action,
FileHyperlinkTarget {
path,
line: None,
column: None,
},
),
SkillsDialogAction::Toggle { skill_id } => self.toggle_skill(&skill_id),
}
}

fn toggle_skill(&mut self, skill_id: &str) {
let result = (|| -> anyhow::Result<()> {
let store = crate::skill::get_skill_store()
.ok_or_else(|| anyhow::anyhow!("Skill store not initialized"))?;
Expand Down Expand Up @@ -18194,6 +18217,95 @@ mod tests {
}
}

#[test]
fn enter_on_a_skill_inserts_the_mention_at_the_cursor() {
let mut app = test_app();
app.input.set_text("xy");
assert!(app
.input
.handle_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)));
app.skills_dialog_state = crate::views::skills_dialog::init_skills_dialog(
"Skills",
vec![crate::ui::components::dialog::DialogItem {
id: "computer-use".to_string(),
name: "computer-use".to_string(),
group: "Skills".to_string(),
description: String::new(),
tip: None,
provider_id: String::new(),
active: true,
}],
);
app.skills_dialog_state.dialog.show();
app.overlay_focus = OverlayFocus::SkillsDialog;
app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.input.get_text(), "x@computer-usey");
assert_eq!(app.overlay_focus, OverlayFocus::None);
}

#[test]
fn skill_source_open_uses_the_configured_editor() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("SKILL.md");
std::fs::write(&path, "# skill\n").unwrap();
let store = crate::skill::SkillStore::for_test([crate::skill::SkillInfo {
name: "alpha".to_string(),
description: None,
location: path.clone(),
content: String::new(),
}]);
let mut app = test_app();
app.editor.open = Some("ed {pathname}".to_string());
app.editor.suspend = true;
app.skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]);
app.skills_dialog_state.refresh(&store);
app.skills_dialog_state.dialog.show();
app.overlay_focus = OverlayFocus::SkillsDialog;

app.handle_keys(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL));
assert!(app.skills_dialog_state.info_is_open());
assert_eq!(app.input.get_text(), "");

app.handle_keys(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
let expected =
crate::utils::file_opener::expand_editor_open_command("ed {pathname}", &path, 1, 1)
.unwrap();
assert_eq!(app.take_editor_suspend(), Some(expected));
assert!(app.skills_dialog_state.info_is_open());
assert_eq!(app.overlay_focus, OverlayFocus::SkillsDialog);
}

#[test]
fn paste_while_skill_info_is_open_does_not_change_search() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("SKILL.md");
std::fs::write(&path, "# skill\n").unwrap();
let store = crate::skill::SkillStore::for_test([crate::skill::SkillInfo {
name: "alpha".to_string(),
description: None,
location: path,
content: String::new(),
}]);
let mut app = test_app();
app.skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]);
app.skills_dialog_state.refresh(&store);
app.skills_dialog_state.dialog.show();
app.skills_dialog_state.dialog.set_search_query("alp");
app.overlay_focus = OverlayFocus::SkillsDialog;

app.handle_keys(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL));
app.handle_paste("nope".to_string());
assert!(app.skills_dialog_state.info_is_open());
assert_eq!(app.skills_dialog_state.dialog.search_query, "alp");
assert_eq!(app.input.get_text(), "");

app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!app.skills_dialog_state.info_is_open());
app.handle_paste("ha".to_string());
assert_eq!(app.skills_dialog_state.dialog.search_query, "alpha");
assert_eq!(app.input.get_text(), "");
}

#[test]
fn skill_suggestions_fill_without_submitting_and_match_remote_discovery() {
let root = tempfile::tempdir().unwrap();
Expand Down
27 changes: 23 additions & 4 deletions src/prompt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ pub(crate) fn render_skill_guidance<'a>(
.into_iter()
.map(|skill| {
format!(
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>file://{}</location>\n </skill>",
" <skill>\n <name>{}</name>\n <description>{}</description>\n </skill>",
skill.name,
skill.description.as_deref().unwrap_or(""),
skill.location.display()
skill.description.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
Expand All @@ -29,6 +28,17 @@ pub(crate) fn render_skill_guidance<'a>(
format!("{SKILL_GUIDANCE_PREFIX}{skills_xml}\n</available_skills>")
}

/// Tokens the enabled catalog adds to every prompt.
///
/// Matches the current OpenCode catalog: name and description only. Paths and
/// skill bodies stay out; the skill tool loads a body by name. The count is
/// the rendered block at ~4 characters per token, not a provider tokenizer.
pub(crate) fn estimate_skill_catalog_tokens<'a>(
skills: impl IntoIterator<Item = &'a crate::skill::SkillInfo>,
) -> usize {
render_skill_guidance(skills).chars().count().div_ceil(4)
}

/// Refresh only our generated catalog in a restored system prompt. Do not
/// rewrite skill content already loaded in conversation history or user rules.
pub(crate) fn refresh_skill_guidance<'a>(
Expand Down Expand Up @@ -491,8 +501,17 @@ mod tests {
assert_eq!(guidance.matches("<available_skills>").count(), 1);
assert!(guidance.contains("<name>test-skill</name>"));
assert!(guidance.contains("<description></description>"));
assert!(guidance.contains("file:///skills/test-skill/SKILL.md"));
assert!(!guidance.contains("<location>"));
assert!(!guidance.contains(skill.location.to_str().unwrap()));
assert!(!guidance.contains("file://"));
assert!(!guidance.contains(&skill.content));
assert_eq!(estimate_skill_catalog_tokens([]), 0);
let mut with_body = skill.clone();
with_body.content = "x".repeat(10_000);
assert_eq!(
estimate_skill_catalog_tokens([&skill]),
estimate_skill_catalog_tokens([&with_body])
);
}

#[test]
Expand Down
19 changes: 15 additions & 4 deletions src/ui/components/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ fn dialog_action_key(hint: &str) -> Option<KeyEvent> {
#[derive(Debug)]
pub struct Dialog {
pub title: String,
/// Secondary header text, drawn after `title` in the muted color.
pub title_note: String,
pub items: Vec<DialogItem>,
pub grouped_items: HashMap<String, Vec<DialogItem>>,
pub filtered_items: Vec<(String, Vec<DialogItem>)>,
Expand Down Expand Up @@ -164,6 +166,7 @@ impl Dialog {
search_textarea.set_placeholder_text("Search");
Self {
title,
title_note: String::new(),
items: Vec::new(),
grouped_items: HashMap::new(),
filtered_items: Vec::new(),
Expand Down Expand Up @@ -1764,13 +1767,20 @@ impl Dialog {
])
.split(chunks[0]);

let title_paragraph = Paragraph::new(Line::from(vec![Span::styled(
&self.title,
let mut title_spans = vec![Span::styled(
self.title.clone(),
Style::default()
.fg(colors.text)
.add_modifier(Modifier::BOLD),
)]))
.alignment(ratatui::layout::Alignment::Left);
)];
if !self.title_note.is_empty() {
title_spans.push(Span::styled(
format!(" {}", self.title_note),
Style::default().fg(colors.text_weak),
));
}
let title_paragraph =
Paragraph::new(Line::from(title_spans)).alignment(ratatui::layout::Alignment::Left);
frame.render_widget(title_paragraph, header_chunks[0]);

let esc_paragraph = Paragraph::new(Line::from(vec![Span::styled(
Expand Down Expand Up @@ -2081,6 +2091,7 @@ impl Clone for Dialog {
fn clone(&self) -> Self {
Self {
title: self.title.clone(),
title_note: self.title_note.clone(),
items: self.items.clone(),
grouped_items: self.grouped_items.clone(),
filtered_items: self.filtered_items.clone(),
Expand Down
Loading
Loading