Compare commits

...

1 Commits

Author SHA1 Message Date
Eric Traut
f7cb7a9d87 tui: list skills via embedded app server 2026-03-13 12:06:58 -06:00
7 changed files with 444 additions and 89 deletions

View File

@@ -40,7 +40,12 @@ use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::SkillErrorInfo as AppServerSkillErrorInfo;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ThreadManager;
@@ -212,6 +217,42 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI
}
}
fn app_server_errors_for_cwd(
cwd: &Path,
response: &SkillsListResponse,
) -> Vec<AppServerSkillErrorInfo> {
response
.data
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.errors.clone())
.unwrap_or_default()
}
fn emit_app_server_skill_load_warnings(
app_event_tx: &AppEventSender,
errors: &[AppServerSkillErrorInfo],
) {
if errors.is_empty() {
return;
}
let error_count = errors.len();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!(
"Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
)),
)));
for error in errors {
let path = error.path.display();
let message = error.message.as_str();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!("{path}: {message}")),
)));
}
}
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
@@ -698,6 +739,7 @@ pub(crate) struct App {
pending_shutdown_exit_thread_id: Option<ThreadId>,
windows_sandbox: WindowsSandboxState,
next_app_server_request_id: i64,
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
@@ -790,6 +832,39 @@ impl App {
}
}
fn next_app_server_request_id(&mut self) -> AppServerRequestId {
let request_id = self.next_app_server_request_id;
self.next_app_server_request_id = self.next_app_server_request_id.saturating_add(1);
AppServerRequestId::Integer(request_id)
}
async fn refresh_skills(&mut self, app_server: &InProcessAppServerClient, force_reload: bool) {
let response = app_server
.request_typed::<SkillsListResponse>(ClientRequest::SkillsList {
request_id: self.next_app_server_request_id(),
params: SkillsListParams {
cwds: Vec::new(),
force_reload,
per_cwd_extra_user_roots: None,
},
})
.await;
match response {
Ok(response) => {
let cwd = self.chat_widget.config_ref().cwd.clone();
let errors = app_server_errors_for_cwd(&cwd, &response);
emit_app_server_skill_load_warnings(&self.app_event_tx, &errors);
self.chat_widget
.set_skills_from_app_server_response(&response);
self.chat_widget.refresh_plugin_mentions();
}
Err(err) => {
tracing::warn!(error = %err, "failed to refresh skills from embedded app server");
}
}
}
async fn rebuild_config_for_resume_or_fallback(
&mut self,
current_cwd: &Path,
@@ -1943,6 +2018,7 @@ impl App {
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
next_app_server_request_id: 1,
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -2031,7 +2107,7 @@ impl App {
app.active_thread_rx.is_some()
) => {
if let Some(event) = active {
if let Err(err) = app.handle_active_thread_event(tui, event).await {
if let Err(err) = app.handle_active_thread_event(tui, event, &app_server).await {
break Err(err);
}
} else {
@@ -3491,6 +3567,18 @@ impl App {
}
}
async fn handle_codex_event_now_with_app_server(
&mut self,
event: Event,
app_server: &InProcessAppServerClient,
) {
let should_refresh_skills = matches!(event.msg, EventMsg::SessionConfigured(_));
self.handle_codex_event_now(event);
if should_refresh_skills {
self.refresh_skills(app_server, true).await;
}
}
fn handle_codex_event_replay(&mut self, event: Event) {
self.chat_widget.handle_codex_event_replay(event);
}
@@ -3500,7 +3588,12 @@ impl App {
/// This function enforces shutdown intent routing: unexpected non-primary
/// thread shutdowns fail over to the primary thread, while user-requested
/// app exits consume only the tracked shutdown completion and then proceed.
async fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> {
async fn handle_active_thread_event(
&mut self,
tui: &mut tui::Tui,
event: Event,
app_server: &InProcessAppServerClient,
) -> Result<()> {
// Capture this before any potential thread switch: we only want to clear
// the exit marker when the currently active thread acknowledges shutdown.
let pending_shutdown_exit_completed = matches!(&event.msg, EventMsg::ShutdownComplete)
@@ -3540,7 +3633,8 @@ impl App {
// thread, so unrelated shutdowns cannot consume this marker.
self.pending_shutdown_exit_thread_id = None;
}
self.handle_codex_event_now(event);
self.handle_codex_event_now_with_app_server(event, app_server)
.await;
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
}
@@ -3915,10 +4009,15 @@ mod tests {
use crate::history_cell::new_session_info;
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessClientStartArgs;
use codex_arg0::Arg0DispatchPaths;
use codex_core::CodexAuth;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
@@ -3948,6 +4047,7 @@ mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::TempDir;
use tempfile::tempdir;
use tokio::time;
@@ -5647,6 +5747,7 @@ mod tests {
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
next_app_server_request_id: 1,
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -5707,6 +5808,7 @@ mod tests {
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
next_app_server_request_id: 1,
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
@@ -5721,6 +5823,34 @@ mod tests {
)
}
fn write_skill(root: &TempDir, name: &str, description: &str) {
let skill_dir = root.path().join("skills").join(name);
std::fs::create_dir_all(&skill_dir).expect("skill dir");
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n");
std::fs::write(skill_dir.join("SKILL.md"), content).expect("skill file");
}
async fn start_test_embedded_app_server(config: Config) -> InProcessAppServerClient {
InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: codex_feedback::CodexFeedback::new(),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-tui-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await
.expect("embedded app server should start")
}
fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
let mut seen = Vec::new();
while let Ok(op) = op_rx.try_recv() {
@@ -5732,6 +5862,105 @@ mod tests {
panic!("expected UserTurn op, saw: {seen:?}");
}
#[tokio::test]
async fn session_configured_fetches_skills_via_embedded_app_server() {
let skills_home = TempDir::new().expect("temp dir");
write_skill(&skills_home, "repo-scout", "Scan the repo");
let workspace = skills_home.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace dir");
let mut app_server_config = ConfigBuilder::default()
.codex_home(skills_home.path().to_path_buf())
.build()
.await
.expect("config");
app_server_config.cwd = workspace.clone();
let app_server = start_test_embedded_app_server(app_server_config).await;
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.handle_codex_event_now_with_app_server(
Event {
id: "session".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: workspace,
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
}),
},
&app_server,
)
.await;
let skills = app.chat_widget.skills().expect("skills should be present");
assert!(skills.iter().any(|skill| skill.name == "repo-scout"));
assert_eq!(op_rx.try_recv(), Ok(Op::ListCustomPrompts));
assert!(
op_rx.try_recv().is_err(),
"session configuration should not submit a legacy ListSkills op"
);
app_server
.shutdown()
.await
.expect("app server shutdown should succeed");
}
#[tokio::test]
async fn app_server_skill_warnings_match_legacy_warning_copy() {
let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let response = SkillsListResponse {
data: vec![codex_app_server_protocol::SkillsListEntry {
cwd: PathBuf::from("/tmp/project"),
skills: Vec::new(),
errors: vec![AppServerSkillErrorInfo {
path: PathBuf::from("/tmp/project/broken/SKILL.md"),
message: "missing description".to_string(),
}],
}],
};
let errors = app_server_errors_for_cwd(Path::new("/tmp/project"), &response);
emit_app_server_skill_load_warnings(&app.app_event_tx, &errors);
let summary = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected summary warning cell, got {other:?}"),
};
let summary_text = summary
.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(summary_text.contains("Skipped loading 1 skill(s)"));
let detail = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected detail warning cell, got {other:?}"),
};
let detail_text = detail
.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(detail_text.contains("/tmp/project/broken/SKILL.md"));
assert!(detail_text.contains("missing description"));
}
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
let model_info = codex_core::test_support::construct_model_info_offline(model, config);
SessionTelemetry::new(

View File

@@ -213,10 +213,10 @@ use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::tui::FrameRequester;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_app_server_protocol::SkillMetadata;
use codex_chatgpt::connectors;
use codex_chatgpt::connectors::AppInfo;
use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -3584,7 +3584,7 @@ impl ChatComposer {
description,
insert_text: format!("${skill_name}"),
search_terms,
path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()),
path: Some(skill.path.to_string_lossy().into_owned()),
category_tag: Some("[Skill]".to_string()),
sort_rank: 1,
});
@@ -4494,6 +4494,8 @@ impl Drop for ChatComposer {
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::SkillInterface;
use codex_app_server_protocol::SkillScope;
use image::ImageBuffer;
use image::Rgba;
use pretty_assertions::assert_eq;
@@ -5355,7 +5357,7 @@ mod tests {
name: "google-calendar-skill".to_string(),
description: "Find availability and plan event changes".to_string(),
short_description: None,
interface: Some(codex_core::skills::model::SkillInterface {
interface: Some(SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: None,
icon_small: None,
@@ -5364,11 +5366,9 @@ mod tests {
default_prompt: None,
}),
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"),
scope: codex_protocol::protocol::SkillScope::Repo,
path: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"),
scope: SkillScope::Repo,
enabled: true,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),

View File

@@ -27,9 +27,9 @@ use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
use bottom_pane_view::BottomPaneView;
use codex_app_server_protocol::SkillMetadata;
use codex_core::features::Features;
use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
@@ -1234,8 +1234,8 @@ mod tests {
use crate::app_event::AppEvent;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use codex_app_server_protocol::SkillScope;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
@@ -1673,11 +1673,9 @@ mod tests {
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("test-skill"),
path: PathBuf::from("test-skill"),
scope: SkillScope::User,
enabled: true,
}]),
});

View File

@@ -51,6 +51,7 @@ use crate::status::rate_limit_snapshot_display_for_limit;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::SkillMetadata as AppServerSkillMetadata;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::config::Config;
@@ -69,7 +70,6 @@ use codex_core::mcp::McpManager;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::plugins::PluginsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::skills::model::SkillMetadata;
use codex_core::terminal::TerminalName;
use codex_core::terminal::terminal_info;
#[cfg(target_os = "windows")]
@@ -128,7 +128,6 @@ use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::TerminalInteractionEvent;
use codex_protocol::protocol::TokenUsage;
@@ -582,7 +581,7 @@ pub(crate) struct ChatWidget {
running_commands: HashMap<String, RunningCommand>,
pending_collab_spawn_requests: HashMap<String, multi_agents::SpawnRequestSummary>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
skills_all: Vec<AppServerSkillMetadata>,
skills_initial_state: Option<HashMap<PathBuf, bool>>,
last_unified_wait: Option<UnifiedExecWaitState>,
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
@@ -1301,10 +1300,6 @@ impl ChatWidget {
}
// Ask codex-core to enumerate custom prompts for this session.
self.submit_op(Op::ListCustomPrompts);
self.submit_op(Op::ListSkills {
cwds: Vec::new(),
force_reload: true,
});
if self.connectors_enabled() {
self.prefetch_connectors();
}
@@ -1370,7 +1365,7 @@ impl ChatWidget {
}
}
fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
fn set_skills(&mut self, skills: Option<Vec<AppServerSkillMetadata>>) {
self.bottom_pane.set_skills(skills);
}
@@ -4594,14 +4589,12 @@ impl ChatWidget {
.strip_prefix("skill://")
.unwrap_or(binding.path.as_str());
let path = Path::new(path);
if let Some(skill) = skills
.iter()
.find(|skill| skill.path_to_skills_md.as_path() == path)
&& selected_skill_paths.insert(skill.path_to_skills_md.clone())
if let Some(skill) = skills.iter().find(|skill| skill.path.as_path() == path)
&& selected_skill_paths.insert(skill.path.clone())
{
items.push(UserInput::Skill {
name: skill.name.clone(),
path: skill.path_to_skills_md.clone(),
path: skill.path.clone(),
});
}
}
@@ -4609,13 +4602,13 @@ impl ChatWidget {
let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills);
for skill in skill_mentions {
if bound_names.contains(skill.name.as_str())
|| !selected_skill_paths.insert(skill.path_to_skills_md.clone())
|| !selected_skill_paths.insert(skill.path.clone())
{
continue;
}
items.push(UserInput::Skill {
name: skill.name.clone(),
path: skill.path_to_skills_md.clone(),
path: skill.path.clone(),
});
}
}
@@ -8329,6 +8322,11 @@ impl ChatWidget {
.collect()
}
#[cfg(test)]
pub(crate) fn skills(&self) -> Option<&Vec<AppServerSkillMetadata>> {
self.bottom_pane.skills()
}
#[cfg(test)]
pub(crate) fn pending_thread_approvals(&self) -> &[String] {
self.bottom_pane.pending_thread_approvals()
@@ -8476,7 +8474,7 @@ impl ChatWidget {
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
fn refresh_plugin_mentions(&mut self) {
pub(crate) fn refresh_plugin_mentions(&mut self) {
if !self.config.features.enabled(Feature::Plugins) {
self.bottom_pane.set_plugin_mentions(None);
return;

View File

@@ -12,16 +12,20 @@ use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_app_server_protocol::SkillDependencies as AppServerSkillDependencies;
use codex_app_server_protocol::SkillInterface as AppServerSkillInterface;
use codex_app_server_protocol::SkillMetadata as AppServerSkillMetadata;
use codex_app_server_protocol::SkillScope as AppServerSkillScope;
use codex_app_server_protocol::SkillToolDependency as AppServerSkillToolDependency;
use codex_app_server_protocol::SkillsListEntry as AppServerSkillsListEntry;
use codex_app_server_protocol::SkillsListResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
use codex_core::skills::model::SkillDependencies;
use codex_core::skills::model::SkillInterface;
use codex_core::skills::model::SkillMetadata;
use codex_core::skills::model::SkillToolDependency;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SkillsListEntry;
use codex_protocol::protocol::SkillMetadata as LegacySkillMetadata;
use codex_protocol::protocol::SkillScope as LegacySkillScope;
use codex_protocol::protocol::SkillsListEntry as LegacySkillsListEntry;
impl ChatWidget {
pub(crate) fn open_skills_list(&mut self) {
@@ -75,11 +79,10 @@ impl ChatWidget {
.skills_all
.iter()
.map(|skill| {
let core_skill = protocol_skill_to_core(skill);
let display_name = skill_display_name(&core_skill).to_string();
let description = skill_description(&core_skill).to_string();
let name = core_skill.name.clone();
let path = core_skill.path_to_skills_md;
let display_name = skill_display_name(skill).to_string();
let description = skill_description(skill).to_string();
let name = skill.name.clone();
let path = skill.path.clone();
SkillsToggleItem {
name: display_name,
skill_name: name,
@@ -101,7 +104,13 @@ impl ChatWidget {
skill.enabled = enabled;
}
}
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
let enabled_skills = self
.skills_all
.iter()
.filter(|skill| skill.enabled)
.cloned()
.collect();
self.set_skills(Some(enabled_skills));
}
pub(crate) fn handle_manage_skills_closed(&mut self) {
@@ -138,13 +147,34 @@ impl ChatWidget {
}
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
let skills = legacy_skills_for_cwd(&self.config.cwd, &response.skills)
.iter()
.map(legacy_skill_to_app_server)
.collect();
self.set_skills_from_listing(skills);
}
pub(crate) fn set_skills_from_app_server_response(&mut self, response: &SkillsListResponse) {
let skills = app_server_skills_for_cwd(&self.config.cwd, &response.data);
self.set_skills_from_listing(skills);
}
fn set_skills_from_listing(&mut self, skills: Vec<AppServerSkillMetadata>) {
self.skills_all = skills;
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
let enabled_skills = self
.skills_all
.iter()
.filter(|skill| skill.enabled)
.cloned()
.collect();
self.set_skills(Some(enabled_skills));
}
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
fn app_server_skills_for_cwd(
cwd: &Path,
skills_entries: &[AppServerSkillsListEntry],
) -> Vec<AppServerSkillMetadata> {
skills_entries
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
@@ -152,35 +182,41 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<Protoco
.unwrap_or_default()
}
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
skills
fn legacy_skills_for_cwd(
cwd: &Path,
skills_entries: &[LegacySkillsListEntry],
) -> Vec<LegacySkillMetadata> {
skills_entries
.iter()
.filter(|skill| skill.enabled)
.map(protocol_skill_to_core)
.collect()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.skills.clone())
.unwrap_or_default()
}
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
SkillMetadata {
fn legacy_skill_to_app_server(skill: &LegacySkillMetadata) -> AppServerSkillMetadata {
AppServerSkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
interface: skill
.interface
.clone()
.map(|interface| AppServerSkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
dependencies: skill
.dependencies
.clone()
.map(|dependencies| SkillDependencies {
.map(|dependencies| AppServerSkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| SkillToolDependency {
.map(|tool| AppServerSkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
@@ -190,11 +226,18 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
})
.collect(),
}),
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: skill.path.clone(),
scope: skill.scope,
path: skill.path.clone(),
scope: legacy_skill_scope_to_app_server(skill.scope),
enabled: skill.enabled,
}
}
fn legacy_skill_scope_to_app_server(scope: LegacySkillScope) -> AppServerSkillScope {
match scope {
LegacySkillScope::User => AppServerSkillScope::User,
LegacySkillScope::Repo => AppServerSkillScope::Repo,
LegacySkillScope::System => AppServerSkillScope::System,
LegacySkillScope::Admin => AppServerSkillScope::Admin,
}
}
@@ -217,8 +260,8 @@ pub(crate) fn collect_tool_mentions(
pub(crate) fn find_skill_mentions_with_tool_mentions(
mentions: &ToolMentions,
skills: &[SkillMetadata],
) -> Vec<SkillMetadata> {
skills: &[AppServerSkillMetadata],
) -> Vec<AppServerSkillMetadata> {
let mention_skill_paths: HashSet<&str> = mentions
.linked_paths
.values()
@@ -228,26 +271,26 @@ pub(crate) fn find_skill_mentions_with_tool_mentions(
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
let mut matches: Vec<AppServerSkillMetadata> = Vec::new();
for skill in skills {
if seen_paths.contains(&skill.path_to_skills_md) {
if seen_paths.contains(&skill.path) {
continue;
}
let path_str = skill.path_to_skills_md.to_string_lossy();
let path_str = skill.path.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
matches.push(skill.clone());
}
}
for skill in skills {
if seen_paths.contains(&skill.path_to_skills_md) {
if seen_paths.contains(&skill.path) {
continue;
}
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_paths.insert(skill.path.clone());
matches.push(skill.clone());
}
}

View File

@@ -17,6 +17,11 @@ use crate::history_cell::UserHistoryCell;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use assert_matches::assert_matches;
use codex_app_server_protocol::SkillInterface;
use codex_app_server_protocol::SkillMetadata;
use codex_app_server_protocol::SkillScope;
use codex_app_server_protocol::SkillsListEntry;
use codex_app_server_protocol::SkillsListResponse;
use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -30,7 +35,6 @@ use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::skills::model::SkillMetadata;
use codex_core::terminal::TerminalName;
use codex_otel::RuntimeMetricsSummary;
use codex_otel::SessionTelemetry;
@@ -88,7 +92,6 @@ use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::TerminalInteractionEvent;
use codex_protocol::protocol::ThreadRolledBackEvent;
@@ -996,11 +999,9 @@ async fn submission_prefers_selected_duplicate_skill_path() {
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: repo_skill_path,
path: repo_skill_path,
scope: SkillScope::Repo,
enabled: true,
},
SkillMetadata {
name: "figma".to_string(),
@@ -1008,11 +1009,9 @@ async fn submission_prefers_selected_duplicate_skill_path() {
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: user_skill_path.clone(),
path: user_skill_path.clone(),
scope: SkillScope::User,
enabled: true,
},
]));
@@ -1041,6 +1040,94 @@ async fn submission_prefers_selected_duplicate_skill_path() {
assert_eq!(selected_skill_paths, vec![user_skill_path]);
}
#[tokio::test]
async fn app_server_skills_response_uses_active_cwd_and_enabled_mentions() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let active_cwd = PathBuf::from("/tmp/project");
chat.config.cwd = active_cwd.clone();
chat.set_skills_from_app_server_response(&SkillsListResponse {
data: vec![
SkillsListEntry {
cwd: PathBuf::from("/tmp/other"),
skills: vec![SkillMetadata {
name: "ignored".to_string(),
description: "Other cwd".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: PathBuf::from("/tmp/other/ignored/SKILL.md"),
scope: SkillScope::Repo,
enabled: true,
}],
errors: Vec::new(),
},
SkillsListEntry {
cwd: active_cwd,
skills: vec![
SkillMetadata {
name: "enabled-skill".to_string(),
description: "Enabled".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: PathBuf::from("/tmp/project/enabled/SKILL.md"),
scope: SkillScope::Repo,
enabled: true,
},
SkillMetadata {
name: "disabled-skill".to_string(),
description: "Disabled".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: PathBuf::from("/tmp/project/disabled/SKILL.md"),
scope: SkillScope::Repo,
enabled: false,
},
],
errors: Vec::new(),
},
],
});
let skills = chat.skills().expect("skills should be populated");
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "enabled-skill");
assert_eq!(
skills[0].path,
PathBuf::from("/tmp/project/enabled/SKILL.md")
);
}
#[tokio::test]
async fn manage_skills_popup_uses_app_server_display_name_and_short_description() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.skills_all = vec![SkillMetadata {
name: "google-calendar-skill".to_string(),
description: "Long description".to_string(),
short_description: Some("Legacy short description".to_string()),
interface: Some(SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: Some("Plan events quickly".to_string()),
icon_small: None,
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
path: PathBuf::from("/tmp/project/google-calendar/SKILL.md"),
scope: SkillScope::Repo,
enabled: true,
}];
chat.open_manage_skills_popup();
let popup = render_bottom_popup(&chat, 72);
assert!(popup.contains("Google Calendar"));
assert!(popup.contains("Plan events quickly"));
}
#[tokio::test]
async fn blocked_image_restore_preserves_mention_bindings() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;

View File

@@ -1,4 +1,4 @@
use codex_core::skills::model::SkillMetadata;
use codex_app_server_protocol::SkillMetadata;
use codex_utils_fuzzy_match::fuzzy_match;
use crate::text_formatting::truncate_text;