diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 348df069fc0..b522896dd8e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1535,10 +1535,22 @@ pub struct TurnInterruptResponse {} #[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum UserInput { - Text { text: String }, - Image { url: String }, - LocalImage { path: PathBuf }, - Skill { name: String, path: PathBuf }, + Text { + text: String, + }, + Image { + url: String, + }, + LocalImage { + path: PathBuf, + }, + Skill { + name: String, + path: PathBuf, + #[serde(default)] + // Experimental; subject to change. + validate_dependencies: bool, + }, } impl UserInput { @@ -1547,7 +1559,15 @@ impl UserInput { UserInput::Text { text } => CoreUserInput::Text { text }, UserInput::Image { url } => CoreUserInput::Image { image_url: url }, UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, - UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Skill { + name, + path, + validate_dependencies, + } => CoreUserInput::Skill { + name, + path, + validate_dependencies, + }, } } } @@ -1558,7 +1578,15 @@ impl From for UserInput { CoreUserInput::Text { text } => UserInput::Text { text }, CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, - CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Skill { + name, + path, + validate_dependencies, + } => UserInput::Skill { + name, + path, + validate_dependencies, + }, _ => unreachable!("unsupported user input variant"), } } @@ -2147,6 +2175,7 @@ mod tests { CoreUserInput::Skill { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + validate_dependencies: false, }, ], }); @@ -2168,6 +2197,7 @@ mod tests { UserInput::Skill { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + validate_dependencies: false, }, ], } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d18a7878f47..4ef92f99ef6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -189,6 +189,7 @@ pub(crate) async fn apply_bespoke_event_handling( }); } }, + EventMsg::SkillDependencyRequest(_ev) => {} EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id, turn_id, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 96e90c5cfa6..b64682ca6a9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -128,11 +128,13 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillDependencyResponse; use crate::skills::SkillError; -use crate::skills::SkillInjections; use crate::skills::SkillMetadata; +use crate::skills::SkillTurnPrep; use crate::skills::SkillsManager; -use crate::skills::build_skill_injections; +use crate::skills::build_skill_turn_prep; +use crate::skills::resolve_skill_dependencies_for_turn; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -681,7 +683,7 @@ impl Session { .await .map(Arc::new); } - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), config.codex_home.clone()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1197,6 +1199,50 @@ impl Session { rx_approve } + pub async fn dependency_env(&self) -> HashMap { + let state = self.state.lock().await; + state.dependency_env() + } + + pub async fn set_dependency_env(&self, values: HashMap) { + let mut state = self.state.lock().await; + state.set_dependency_env(values); + } + + pub async fn codex_home(&self) -> PathBuf { + let state = self.state.lock().await; + state.codex_home() + } + + pub async fn insert_pending_skill_dependencies( + &self, + request_id: String, + tx_response: oneshot::Sender, + ) -> Option> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_skill_dependencies(request_id, tx_response) + } + None => None, + } + } + + pub async fn remove_pending_skill_dependencies( + &self, + request_id: &str, + ) -> Option> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_skill_dependencies(request_id) + } + None => None, + } + } + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { let mut active = self.active_turn.lock().await; @@ -1725,6 +1771,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv } => { handlers::resolve_elicitation(&sess, server_name, request_id, decision).await; } + Op::ResolveSkillDependencies { id, values } => { + handlers::resolve_skill_dependencies(&sess, id, values).await; + } Op::Shutdown => { if handlers::shutdown(&sess, sub.id.clone()).await { break; @@ -1751,6 +1800,8 @@ mod handlers { use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; + use crate::skills::SkillDependencyResponse; + use crate::skills::handle_skill_dependency_response; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; @@ -1775,6 +1826,7 @@ mod handlers { use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use mcp_types::RequestId; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tracing::info; @@ -1908,6 +1960,15 @@ mod handlers { } } + pub async fn resolve_skill_dependencies( + sess: &Arc, + request_id: String, + values: HashMap, + ) { + handle_skill_dependency_response(sess, &request_id, SkillDependencyResponse { values }) + .await; + } + /// Propagate a user's exec approval decision to the session. /// Also optionally applies an execpolicy amendment. pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { @@ -2354,10 +2415,11 @@ pub(crate) async fn run_task( .await, ); - let SkillInjections { + let SkillTurnPrep { items: skill_items, + dependencies: skill_dependencies, warnings: skill_warnings, - } = build_skill_injections(&input, skills_outcome.as_ref()).await; + } = build_skill_turn_prep(&input, skills_outcome.as_ref()).await; for message in skill_warnings { sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) @@ -2369,6 +2431,8 @@ pub(crate) async fn run_task( sess.record_response_item_and_emit_turn_item(turn_context.as_ref(), response_item) .await; + resolve_skill_dependencies_for_turn(&sess, &turn_context, &skill_dependencies).await; + if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) .await; @@ -3178,7 +3242,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, config.codex_home.clone()); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 10.0, @@ -3244,7 +3308,7 @@ mod tests { session_source: SessionSource::Exec, }; - let mut state = SessionState::new(session_configuration); + let mut state = SessionState::new(session_configuration, config.codex_home.clone()); let initial = RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 15.0, @@ -3505,7 +3569,7 @@ mod tests { session_configuration.session_source.clone(), ); - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), config.codex_home.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { @@ -3599,7 +3663,7 @@ mod tests { session_configuration.session_source.clone(), ); - let state = SessionState::new(session_configuration.clone()); + let state = SessionState::new(session_configuration.clone(), config.codex_home.clone()); let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 6c02ad09425..95fee42b869 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -69,6 +69,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ExecApprovalRequest(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::SkillDependencyRequest(_) | EventMsg::BackgroundEvent(_) | EventMsg::StreamError(_) | EventMsg::PatchApplyBegin(_) diff --git a/codex-rs/core/src/skills/dependencies.rs b/codex-rs/core/src/skills/dependencies.rs new file mode 100644 index 00000000000..1d014c6a9b7 --- /dev/null +++ b/codex-rs/core/src/skills/dependencies.rs @@ -0,0 +1,89 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillDependency { + pub(crate) name: String, + pub(crate) description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillDependencyInfo { + pub(crate) skill_name: String, + pub(crate) dependency: SkillDependency, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillDependencyResponse { + pub(crate) values: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Deserialize)] +struct SkillDependencyEntry { + #[serde(rename = "type")] + dep_type: String, + name: Option, + description: Option, +} + +pub(crate) fn parse_env_var_dependencies(contents: &str) -> Vec { + let Some(frontmatter) = extract_frontmatter(contents) else { + return Vec::new(); + }; + + let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) { + Ok(parsed) => parsed, + Err(_) => return Vec::new(), + }; + + parsed + .dependencies + .into_iter() + .filter_map(|entry| { + if entry.dep_type != "env_var" { + return None; + } + let name = entry.name.map(|value| sanitize_single_line(&value))?; + if name.is_empty() { + return None; + } + let description = entry + .description + .map(|value| sanitize_single_line(&value)) + .filter(|value| !value.is_empty()); + Some(SkillDependency { name, description }) + }) + .collect() +} + +fn extract_frontmatter(contents: &str) -> Option { + let mut lines = contents.lines(); + if !matches!(lines.next(), Some(line) if line.trim() == "---") { + return None; + } + + let mut frontmatter_lines: Vec<&str> = Vec::new(); + let mut found_closing = false; + for line in lines.by_ref() { + if line.trim() == "---" { + found_closing = true; + break; + } + frontmatter_lines.push(line); + } + + if frontmatter_lines.is_empty() || !found_closing { + return None; + } + + Some(frontmatter_lines.join("\n")) +} + +fn sanitize_single_line(raw: &str) -> String { + raw.split_whitespace().collect::>().join(" ") +} diff --git a/codex-rs/core/src/skills/dependency_flow.rs b/codex-rs/core/src/skills/dependency_flow.rs new file mode 100644 index 00000000000..b5a674c0b79 --- /dev/null +++ b/codex-rs/core/src/skills/dependency_flow.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::env; +use std::sync::Arc; + +use tracing::warn; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::protocol::EventMsg; +use crate::protocol::SkillDependencyRequestEvent; +use crate::skills::SkillDependencyInfo; +use crate::skills::SkillDependencyResponse; +use crate::skills::load_env_var; +use crate::skills::save_env_var; + +/// Resolve required dependency values (session cache, env vars, then env store), +/// and prompt the UI for any missing ones. +pub(crate) async fn resolve_skill_dependencies_for_turn( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) { + if dependencies.is_empty() { + return; + } + + let codex_home = sess.codex_home().await; + let existing_env = sess.dependency_env().await; + let mut loaded_values = HashMap::new(); + let mut missing = Vec::new(); + let mut seen_names = HashSet::new(); + + for dependency in dependencies { + let name = dependency.dependency.name.clone(); + if !seen_names.insert(name.clone()) { + continue; + } + if existing_env.contains_key(&name) { + continue; + } + match env::var(&name) { + Ok(value) => { + loaded_values.insert(name.clone(), value); + continue; + } + Err(env::VarError::NotPresent) => {} + Err(err) => { + warn!("failed to read env var {name}: {err}"); + } + } + + match load_env_var(&codex_home, &name) { + Ok(Some(value)) => { + loaded_values.insert(name.clone(), value); + } + Ok(None) => { + missing.push(dependency.clone()); + } + Err(err) => { + warn!("failed to load env var {name}: {err}"); + missing.push(dependency.clone()); + } + } + } + + if !loaded_values.is_empty() { + sess.set_dependency_env(loaded_values).await; + } + + if !missing.is_empty() { + let _response = request_skill_dependencies(sess, turn_context, &missing).await; + } +} + +/// Emit per-skill dependency requests and wait for user responses. +pub(crate) async fn request_skill_dependencies( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) -> Vec { + let mut grouped: HashMap> = HashMap::new(); + for dependency in dependencies { + grouped + .entry(dependency.skill_name.clone()) + .or_default() + .push(dependency.clone()); + } + + let mut responses = Vec::with_capacity(grouped.len()); + for (skill_name, deps) in grouped { + let request_id = format!("skill_dependencies:{}:{}", turn_context.sub_id, skill_name); + let (tx_response, rx_response) = tokio::sync::oneshot::channel(); + let prev_entry = sess + .insert_pending_skill_dependencies(request_id.clone(), tx_response) + .await; + if prev_entry.is_some() { + warn!("Overwriting existing pending skill dependencies for {request_id}"); + } + + let dependencies = deps + .into_iter() + .map(|dep| codex_protocol::approvals::SkillDependency { + dependency_type: "env_var".to_string(), + name: dep.dependency.name, + description: dep.dependency.description, + }) + .collect::>(); + + let event = EventMsg::SkillDependencyRequest(SkillDependencyRequestEvent { + id: request_id.clone(), + turn_id: turn_context.sub_id.clone(), + skill_name, + dependencies, + }); + sess.send_event(turn_context, event).await; + + let response = rx_response.await.unwrap_or(SkillDependencyResponse { + values: HashMap::new(), + }); + responses.push(response); + } + + responses +} + +/// Persist provided values, update session env, and unblock the pending request. +pub(crate) async fn handle_skill_dependency_response( + sess: &Arc, + request_id: &str, + response: SkillDependencyResponse, +) { + if !response.values.is_empty() { + let codex_home = sess.codex_home().await; + for (name, value) in &response.values { + if let Err(err) = save_env_var(&codex_home, name, value) { + warn!("failed to persist env var {name}: {err}"); + } + } + sess.set_dependency_env(response.values.clone()).await; + } + let entry = sess.remove_pending_skill_dependencies(request_id).await; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending skill dependency request found for {request_id}"); + } + } +} diff --git a/codex-rs/core/src/skills/env_store.rs b/codex-rs/core/src/skills/env_store.rs new file mode 100644 index 00000000000..1662f2e3990 --- /dev/null +++ b/codex-rs/core/src/skills/env_store.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use codex_keyring_store::DefaultKeyringStore; +use codex_keyring_store::KeyringStore; +use tracing::warn; + +const KEYRING_SERVICE: &str = "Codex Env Vars"; +const ENV_VARS_FILE: &str = "env_vars.json"; + +/// Env var persistence uses keyring first, then falls back to a local file. +/// Keys are the raw env var names, without skill-level namespacing. +/// The file lives at `codex_home/env_vars.json` with 0600 permissions on Unix. +pub(crate) fn load_env_var(codex_home: &Path, name: &str) -> io::Result> { + let keyring_store = DefaultKeyringStore; + match keyring_store.load(KEYRING_SERVICE, name) { + Ok(Some(value)) => return Ok(Some(value)), + Ok(None) => {} + Err(error) => { + warn!("failed to read env var from keyring: {}", error.message()); + } + } + + load_env_var_from_file(codex_home, name) +} + +pub(crate) fn save_env_var(codex_home: &Path, name: &str, value: &str) -> io::Result<()> { + let keyring_store = DefaultKeyringStore; + match keyring_store.save(KEYRING_SERVICE, name, value) { + Ok(()) => { + let _ = delete_env_var_from_file(codex_home, name); + return Ok(()); + } + Err(error) => { + warn!("failed to write env var to keyring: {}", error.message()); + } + } + + save_env_var_to_file(codex_home, name, value) +} + +fn env_vars_file_path(codex_home: &Path) -> PathBuf { + codex_home.join(ENV_VARS_FILE) +} + +fn load_env_var_from_file(codex_home: &Path, name: &str) -> io::Result> { + let env_file = env_vars_file_path(codex_home); + let contents = match fs::read_to_string(&env_file) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let env_map: HashMap = serde_json::from_str(&contents)?; + Ok(env_map.get(name).cloned()) +} + +fn save_env_var_to_file(codex_home: &Path, name: &str, value: &str) -> io::Result<()> { + let env_file = env_vars_file_path(codex_home); + if let Some(parent) = env_file.parent() { + fs::create_dir_all(parent)?; + } + let mut env_map: HashMap = match fs::read_to_string(&env_file) { + Ok(contents) => serde_json::from_str(&contents)?, + Err(err) if err.kind() == io::ErrorKind::NotFound => HashMap::new(), + Err(err) => return Err(err), + }; + env_map.insert(name.to_string(), value.to_string()); + let json_data = serde_json::to_string_pretty(&env_map)?; + let mut options = fs::OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&env_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) +} + +fn delete_env_var_from_file(codex_home: &Path, name: &str) -> io::Result { + let env_file = env_vars_file_path(codex_home); + let contents = match fs::read_to_string(&env_file) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + let mut env_map: HashMap = serde_json::from_str(&contents)?; + let removed = env_map.remove(name).is_some(); + if !removed { + return Ok(false); + } + if env_map.is_empty() { + fs::remove_file(env_file)?; + return Ok(true); + } + let json_data = serde_json::to_string_pretty(&env_map)?; + let mut options = fs::OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&env_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(true) +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs deleted file mode 100644 index a143fce1f22..00000000000 --- a/codex-rs/core/src/skills/injection.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::collections::HashSet; - -use crate::skills::SkillLoadOutcome; -use crate::skills::SkillMetadata; -use crate::user_instructions::SkillInstructions; -use codex_protocol::models::ResponseItem; -use codex_protocol::user_input::UserInput; -use tokio::fs; - -#[derive(Debug, Default)] -pub(crate) struct SkillInjections { - pub(crate) items: Vec, - pub(crate) warnings: Vec, -} - -pub(crate) async fn build_skill_injections( - inputs: &[UserInput], - skills: Option<&SkillLoadOutcome>, -) -> SkillInjections { - if inputs.is_empty() { - return SkillInjections::default(); - } - - let Some(outcome) = skills else { - return SkillInjections::default(); - }; - - let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); - if mentioned_skills.is_empty() { - return SkillInjections::default(); - } - - let mut result = SkillInjections { - items: Vec::with_capacity(mentioned_skills.len()), - warnings: Vec::new(), - }; - - for skill in mentioned_skills { - match fs::read_to_string(&skill.path).await { - Ok(contents) => { - result.items.push(ResponseItem::from(SkillInstructions { - name: skill.name, - path: skill.path.to_string_lossy().into_owned(), - contents, - })); - } - Err(err) => { - let message = format!( - "Failed to load skill {} at {}: {err:#}", - skill.name, - skill.path.display() - ); - result.warnings.push(message); - } - } - } - - result -} - -fn collect_explicit_skill_mentions( - inputs: &[UserInput], - skills: &[SkillMetadata], -) -> Vec { - let mut selected: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - for input in inputs { - if let UserInput::Skill { name, path } = input - && seen.insert(name.clone()) - && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) - { - selected.push(skill.clone()); - } - } - - selected -} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index cf7c180502b..0c40a6d5717 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,15 +1,25 @@ -pub mod injection; +mod dependencies; +mod dependency_flow; +mod env_store; pub mod loader; pub mod manager; pub mod model; +pub mod prepare_turn; pub mod render; pub mod system; -pub(crate) use injection::SkillInjections; -pub(crate) use injection::build_skill_injections; +pub(crate) use dependencies::SkillDependencyInfo; +pub(crate) use dependencies::SkillDependencyResponse; +pub(crate) use dependencies::parse_env_var_dependencies; +pub(crate) use dependency_flow::handle_skill_dependency_response; +pub(crate) use dependency_flow::resolve_skill_dependencies_for_turn; +pub(crate) use env_store::load_env_var; +pub(crate) use env_store::save_env_var; pub use loader::load_skills; pub use manager::SkillsManager; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; +pub(crate) use prepare_turn::SkillTurnPrep; +pub(crate) use prepare_turn::build_skill_turn_prep; pub use render::render_skills_section; diff --git a/codex-rs/core/src/skills/prepare_turn.rs b/codex-rs/core/src/skills/prepare_turn.rs new file mode 100644 index 00000000000..06843a71d3b --- /dev/null +++ b/codex-rs/core/src/skills/prepare_turn.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use crate::skills::SkillDependencyInfo; +use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::skills::parse_env_var_dependencies; +use crate::user_instructions::SkillInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use tokio::fs; + +#[derive(Debug, Default)] +pub(crate) struct SkillTurnPrep { + pub(crate) items: Vec, + pub(crate) dependencies: Vec, + pub(crate) warnings: Vec, +} + +/// Builds turn-ready skill instructions and collects declared dependencies from SKILL.md. +pub(crate) async fn build_skill_turn_prep( + inputs: &[UserInput], + skills: Option<&SkillLoadOutcome>, +) -> SkillTurnPrep { + if inputs.is_empty() { + return SkillTurnPrep::default(); + } + + let Some(outcome) = skills else { + return SkillTurnPrep::default(); + }; + + let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + if mentioned_skills.is_empty() { + return SkillTurnPrep::default(); + } + + let mut result = SkillTurnPrep { + items: Vec::with_capacity(mentioned_skills.len()), + dependencies: Vec::new(), + warnings: Vec::new(), + }; + + for selection in mentioned_skills { + let skill = selection.skill; + match fs::read_to_string(&skill.path).await { + Ok(contents) => { + if selection.validate_dependencies { + for dependency in parse_env_var_dependencies(&contents) { + result.dependencies.push(SkillDependencyInfo { + skill_name: skill.name.clone(), + dependency, + }); + } + } + result.items.push(ResponseItem::from(SkillInstructions { + name: skill.name, + path: skill.path.to_string_lossy().into_owned(), + contents, + })); + } + Err(err) => { + let message = format!( + "Failed to load skill {} at {}: {err:#}", + skill.name, + skill.path.display() + ); + result.warnings.push(message); + } + } + } + + result +} + +struct SkillSelection { + skill: SkillMetadata, + validate_dependencies: bool, +} + +fn collect_explicit_skill_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], +) -> Vec { + let mut selected: Vec = Vec::new(); + let mut seen: HashSet<(String, std::path::PathBuf)> = HashSet::new(); + let mut indexes: HashMap<(String, std::path::PathBuf), usize> = HashMap::new(); + + for input in inputs { + if let UserInput::Skill { + name, + path, + validate_dependencies, + } = input + && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + { + let key = (name.clone(), path.clone()); + if let Some(index) = indexes.get(&key) { + if *validate_dependencies { + selected[*index].validate_dependencies = true; + } + continue; + } + if seen.insert(key.clone()) { + indexes.insert(key.clone(), selected.len()); + selected.push(SkillSelection { + skill: skill.clone(), + validate_dependencies: *validate_dependencies, + }); + } + } + } + + selected +} diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d1883735..f80ea296c19 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,5 +1,8 @@ //! Session-wide mutable state. +use std::collections::HashMap; +use std::path::PathBuf; + use codex_protocol::models::ResponseItem; use crate::codex::SessionConfiguration; @@ -14,19 +17,37 @@ pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, + pub(crate) dependency_env: HashMap, + codex_home: PathBuf, } impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { + pub(crate) fn new(session_configuration: SessionConfiguration, codex_home: PathBuf) -> Self { let history = ContextManager::new(); Self { session_configuration, history, latest_rate_limits: None, + dependency_env: HashMap::new(), + codex_home, + } + } + + pub(crate) fn set_dependency_env(&mut self, values: HashMap) { + for (key, value) in values { + self.dependency_env.insert(key, value); } } + pub(crate) fn dependency_env(&self) -> HashMap { + self.dependency_env.clone() + } + + pub(crate) fn codex_home(&self) -> PathBuf { + self.codex_home.clone() + } + // History helpers pub(crate) fn record_items(&mut self, items: I, policy: TruncationPolicy) where diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index e2fff0554e7..5f3567b8e91 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -13,6 +13,7 @@ use tokio::sync::oneshot; use crate::codex::TurnContext; use crate::protocol::ReviewDecision; +use crate::skills::SkillDependencyResponse; use crate::tasks::SessionTask; /// Metadata about the currently running turn. @@ -67,6 +68,7 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_skill_dependencies: HashMap>, pending_input: Vec, } @@ -88,6 +90,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_skill_dependencies.clear(); self.pending_input.clear(); } @@ -104,6 +107,21 @@ impl TurnState { ret } } + + pub(crate) fn insert_pending_skill_dependencies( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_skill_dependencies.insert(key, tx) + } + + pub(crate) fn remove_pending_skill_dependencies( + &mut self, + key: &str, + ) -> Option> { + self.pending_skill_dependencies.remove(key) + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 0e14da68f26..23cba488d1a 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -203,6 +203,11 @@ impl ShellHandler { call_id: String, freeform: bool, ) -> Result { + let mut exec_params = exec_params; + let dependency_env = session.dependency_env().await; + if !dependency_env.is_empty() { + exec_params.env.extend(dependency_env); + } // Approval policy guard for explicit escalation in non-OnRequest modes. if exec_params .sandbox_permissions diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 8e9266ee868..f2b2cf4e12e 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -68,6 +68,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { UserInput::Skill { name: "demo".to_string(), path: skill_path.clone(), + validate_dependencies: false, }, ], final_output_json_schema: None, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index ba6d99c5a82..d8dcd7cf741 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -593,6 +593,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::SkillDependencyRequest(_) | EventMsg::SkillsUpdateAvailable | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 73b75dcbf83..5b8b4e6d5c2 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -300,6 +300,7 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::SkillDependencyRequest(_) | EventMsg::SkillsUpdateAvailable | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 78050dfa860..d68eb65b2c5 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -93,3 +93,22 @@ pub struct ApplyPatchApprovalRequestEvent { #[serde(skip_serializing_if = "Option::is_none")] pub grant_root: Option, } + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillDependency { + #[serde(rename = "type")] + pub dependency_type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillDependencyRequestEvent { + pub id: String, + /// Turn ID that this dependency request belongs to. + #[serde(default)] + pub turn_id: String, + pub skill_name: String, + pub dependencies: Vec, +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e3748bafc6b..8bc63d1e769 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -42,6 +42,8 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::approvals::SkillDependency; +pub use crate::approvals::SkillDependencyRequestEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -170,6 +172,14 @@ pub enum Op { decision: ElicitationAction, }, + /// Resolve a skill dependency request (e.g., env vars). + ResolveSkillDependencies { + /// Request identifier from the skill dependency request. + id: String, + /// Provided dependency values keyed by dependency name. + values: HashMap, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has @@ -620,6 +630,8 @@ pub enum EventMsg { ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), + SkillDependencyRequest(SkillDependencyRequestEvent), + /// Notification advising the user that something they are using has been /// deprecated and should be phased out. DeprecationNotice(DeprecationNoticeEvent), diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 26773e1a1a8..6d7e1cb1c0b 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -26,5 +26,7 @@ pub enum UserInput { Skill { name: String, path: std::path::PathBuf, + #[serde(default)] + validate_dependencies: bool, }, } diff --git a/codex-rs/tui/src/bottom_pane/dependency_input_view.rs b/codex-rs/tui/src/bottom_pane/dependency_input_view.rs new file mode 100644 index 00000000000..fc0685a932e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/dependency_input_view.rs @@ -0,0 +1,345 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; + +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SkillDependency; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; +use crate::key_hint; +use crate::render::renderable::Renderable; + +pub(crate) struct DependencyInputView { + request_id: String, + skill_name: String, + dependencies: Vec, + app_event_tx: AppEventSender, + textarea: TextArea, + textarea_state: RefCell, + complete: bool, + current_index: usize, + values: HashMap, +} + +impl DependencyInputView { + pub(crate) fn new( + request_id: String, + skill_name: String, + dependencies: Vec, + app_event_tx: AppEventSender, + ) -> Self { + Self { + request_id, + skill_name, + dependencies, + app_event_tx, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + current_index: 0, + values: HashMap::new(), + } + } + + fn submit(&mut self, values: HashMap) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::ResolveSkillDependencies { + id: self.request_id.clone(), + values, + })); + self.complete = true; + } + + fn advance_with_value(&mut self) { + let Some(dependency) = self.dependencies.get(self.current_index) else { + return; + }; + let value = self.textarea.text().trim().to_string(); + if value.is_empty() { + return; + } + self.values.insert(dependency.name.clone(), value); + self.textarea.set_text(""); + if self.current_index + 1 >= self.dependencies.len() { + let values = std::mem::take(&mut self.values); + self.submit(values); + } else { + self.current_index = self.current_index.saturating_add(1); + } + } +} + +impl BottomPaneView for DependencyInputView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.advance_with_value(); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => self.advance_with_value(), + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.submit(HashMap::new()); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for DependencyInputView { + fn desired_height(&self, width: u16) -> u16 { + let input_height = self.input_height(width); + 5u16 + input_height + 3u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let input_height = self.input_height(area.width); + let mut cursor_y = area.y; + + let hint_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let hint_spans: Vec> = vec![ + gutter(), + format!( + "${name} relies on one or more environment variables.", + name = self.skill_name + ) + .into(), + ]; + Paragraph::new(Line::from(hint_spans)).render(hint_area, buf); + cursor_y = cursor_y.saturating_add(1); + + let followup_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let followup_spans: Vec> = vec![ + gutter(), + "Press Esc to continue anyway and set them yourself, or provide them here and let Codex handle it.".into(), + ]; + Paragraph::new(Line::from(followup_spans)).render(followup_area, buf); + cursor_y = cursor_y.saturating_add(2); + + if cursor_y < area.y.saturating_add(area.height) { + let input_hint_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let step_hint = if self.dependencies.len() > 1 { + format!( + " (step {step}/{total})", + step = self.current_index.saturating_add(1), + total = self.dependencies.len() + ) + } else { + String::new() + }; + let input_hint_spans: Vec> = vec![ + gutter(), + format!( + "Enter a value for {name}{step_hint}.", + name = self.current_dependency_name(), + step_hint = step_hint + ) + .into(), + ]; + Paragraph::new(Line::from(input_hint_spans)).render(input_hint_area, buf); + cursor_y = cursor_y.saturating_add(1); + } + + if cursor_y < area.y.saturating_add(area.height) { + let dep_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + if let Some(dependency) = self.dependencies.get(self.current_index) { + let mut spans: Vec> = vec![ + gutter(), + "- ".into(), + dependency.name.clone().green().bold(), + ]; + if let Some(description) = &dependency.description { + spans.push(" - ".dim()); + spans.push(description.clone().dim()); + } + Paragraph::new(Line::from(spans)).render(dep_area, buf); + cursor_y = cursor_y.saturating_add(1); + } + } + + let input_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from("Value".dim())).render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(dependency_popup_hint_line()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let input_height = self.input_height(area.width); + let input_y = area.y.saturating_add(5); + let text_area_height = input_height.saturating_sub(1); + if text_area_height == 0 { + return None; + } + let text_area = Rect { + x: area.x.saturating_add(2), + y: input_y.saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = self.textarea_state.borrow(); + self.textarea + .cursor_pos_with_state(text_area, state.clone()) + } +} + +impl DependencyInputView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 5); + text_height.saturating_add(1).min(6) + } + + fn current_dependency_name(&self) -> String { + self.dependencies + .get(self.current_index) + .map(|dependency| dependency.name.clone()) + .unwrap_or_else(|| "the next value".to_string()) + } +} + +fn gutter() -> Span<'static> { + " ".into() +} + +fn dependency_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to provide it here, or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to exit and set it manually.".into(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fe626537ac4..bf393294da8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,6 +26,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod dependency_input_view; mod experimental_features_view; mod file_search_popup; mod footer; @@ -43,6 +44,7 @@ mod scroll_state; mod selection_popup_common; mod textarea; mod unified_exec_footer; +pub(crate) use dependency_input_view::DependencyInputView; pub(crate) use feedback_view::FeedbackNoteView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fa0be84a745..f26ad5a7479 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -67,6 +67,7 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::SkillDependencyRequestEvent; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -91,6 +92,7 @@ use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::DependencyInputView; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; @@ -866,6 +868,14 @@ impl ChatWidget { ); } + fn on_skill_dependency_request(&mut self, ev: SkillDependencyRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_skill_dependencies(ev), + |s| s.handle_skill_dependency_request_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -1296,6 +1306,19 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_skill_dependency_request_now(&mut self, ev: SkillDependencyRequestEvent) { + self.flush_answer_stream_with_separator(); + + let view = DependencyInputView::new( + ev.id, + ev.skill_name, + ev.dependencies, + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1978,6 +2001,7 @@ impl ChatWidget { items.push(UserInput::Skill { name: skill.name.clone(), path: skill.path.clone(), + validate_dependencies: true, }); } } @@ -2098,6 +2122,9 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } + EventMsg::SkillDependencyRequest(ev) => { + self.on_skill_dependency_request(ev); + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea55..820c1466d83 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -8,6 +8,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::protocol::SkillDependencyRequestEvent; use super::ChatWidget; @@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), + SkillDependencies(SkillDependencyRequestEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -57,6 +59,10 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + pub(crate) fn push_skill_dependencies(&mut self, ev: SkillDependencyRequestEvent) { + self.queue.push_back(QueuedInterrupt::SkillDependencies(ev)); + } + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } @@ -85,6 +91,9 @@ impl InterruptManager { chat.handle_apply_patch_approval_now(id, ev) } QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::SkillDependencies(ev) => { + chat.handle_skill_dependency_request_now(ev) + } QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), diff --git a/codex-rs/tui2/src/bottom_pane/dependency_input_view.rs b/codex-rs/tui2/src/bottom_pane/dependency_input_view.rs new file mode 100644 index 00000000000..e24196a877c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/dependency_input_view.rs @@ -0,0 +1,345 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; + +use codex_core::protocol::Op; +use codex_core::protocol::SkillDependency; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; +use crate::key_hint; +use crate::render::renderable::Renderable; + +pub(crate) struct DependencyInputView { + request_id: String, + skill_name: String, + dependencies: Vec, + app_event_tx: AppEventSender, + textarea: TextArea, + textarea_state: RefCell, + complete: bool, + current_index: usize, + values: HashMap, +} + +impl DependencyInputView { + pub(crate) fn new( + request_id: String, + skill_name: String, + dependencies: Vec, + app_event_tx: AppEventSender, + ) -> Self { + Self { + request_id, + skill_name, + dependencies, + app_event_tx, + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + complete: false, + current_index: 0, + values: HashMap::new(), + } + } + + fn submit(&mut self, values: HashMap) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::ResolveSkillDependencies { + id: self.request_id.clone(), + values, + })); + self.complete = true; + } + + fn advance_with_value(&mut self) { + let Some(dependency) = self.dependencies.get(self.current_index) else { + return; + }; + let value = self.textarea.text().trim().to_string(); + if value.is_empty() { + return; + } + self.values.insert(dependency.name.clone(), value); + self.textarea.set_text(""); + if self.current_index + 1 >= self.dependencies.len() { + let values = std::mem::take(&mut self.values); + self.submit(values); + } else { + self.current_index = self.current_index.saturating_add(1); + } + } +} + +impl BottomPaneView for DependencyInputView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.advance_with_value(); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => self.advance_with_value(), + other => { + self.textarea.input(other); + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.submit(HashMap::new()); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.textarea.insert_str(&pasted); + true + } +} + +impl Renderable for DependencyInputView { + fn desired_height(&self, width: u16) -> u16 { + let input_height = self.input_height(width); + 5u16 + input_height + 3u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let input_height = self.input_height(area.width); + let mut cursor_y = area.y; + + let hint_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let hint_spans: Vec> = vec![ + gutter(), + format!( + "${name} relies on one or more environment variables.", + name = self.skill_name + ) + .into(), + ]; + Paragraph::new(Line::from(hint_spans)).render(hint_area, buf); + cursor_y = cursor_y.saturating_add(1); + + let followup_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let followup_spans: Vec> = vec![ + gutter(), + "Press Esc to continue anyway and set them yourself, or provide them here and let Codex handle it.".into(), + ]; + Paragraph::new(Line::from(followup_spans)).render(followup_area, buf); + cursor_y = cursor_y.saturating_add(2); + + if cursor_y < area.y.saturating_add(area.height) { + let input_hint_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + let step_hint = if self.dependencies.len() > 1 { + format!( + " (step {step}/{total})", + step = self.current_index.saturating_add(1), + total = self.dependencies.len() + ) + } else { + String::new() + }; + let input_hint_spans: Vec> = vec![ + gutter(), + format!( + "Enter a value for {name}{step_hint}.", + name = self.current_dependency_name(), + step_hint = step_hint + ) + .into(), + ]; + Paragraph::new(Line::from(input_hint_spans)).render(input_hint_area, buf); + cursor_y = cursor_y.saturating_add(1); + } + + if cursor_y < area.y.saturating_add(area.height) { + let dep_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + if let Some(dependency) = self.dependencies.get(self.current_index) { + let mut spans: Vec> = vec![ + gutter(), + "- ".into(), + dependency.name.clone().green().bold(), + ]; + if let Some(description) = &dependency.description { + spans.push(" - ".dim()); + spans.push(description.clone().dim()); + } + Paragraph::new(Line::from(spans)).render(dep_area, buf); + cursor_y = cursor_y.saturating_add(1); + } + } + + let input_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: input_height, + }; + if input_area.width >= 2 { + for row in 0..input_area.height { + Paragraph::new(Line::from(vec![gutter()])).render( + Rect { + x: input_area.x, + y: input_area.y.saturating_add(row), + width: 2, + height: 1, + }, + buf, + ); + } + + let text_area_height = input_area.height.saturating_sub(1); + if text_area_height > 0 { + if input_area.width > 2 { + let blank_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y, + width: input_area.width.saturating_sub(2), + height: 1, + }; + Clear.render(blank_rect, buf); + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(2), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + Paragraph::new(Line::from("Value".dim())).render(textarea_rect, buf); + } + } + } + + let hint_blank_y = input_area.y.saturating_add(input_height); + if hint_blank_y < area.y.saturating_add(area.height) { + let blank_area = Rect { + x: area.x, + y: hint_blank_y, + width: area.width, + height: 1, + }; + Clear.render(blank_area, buf); + } + + let hint_y = hint_blank_y.saturating_add(1); + if hint_y < area.y.saturating_add(area.height) { + Paragraph::new(dependency_popup_hint_line()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let input_height = self.input_height(area.width); + let input_y = area.y.saturating_add(5); + let text_area_height = input_height.saturating_sub(1); + if text_area_height == 0 { + return None; + } + let text_area = Rect { + x: area.x.saturating_add(2), + y: input_y.saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = self.textarea_state.borrow(); + self.textarea + .cursor_pos_with_state(text_area, state.clone()) + } +} + +impl DependencyInputView { + fn input_height(&self, width: u16) -> u16 { + let usable_width = width.saturating_sub(2); + let text_height = self.textarea.desired_height(usable_width).clamp(1, 5); + text_height.saturating_add(1).min(6) + } + + fn current_dependency_name(&self) -> String { + self.dependencies + .get(self.current_index) + .map(|dependency| dependency.name.clone()) + .unwrap_or_else(|| "the next value".to_string()) + } +} + +fn gutter() -> Span<'static> { + " ".into() +} + +fn dependency_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to provide it here, or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to exit and set it manually.".into(), + ]) +} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 4b6caf0d1aa..86eb17f12a4 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -25,6 +25,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod dependency_input_view; mod file_search_popup; mod footer; mod list_selection_view; @@ -40,6 +41,7 @@ mod queued_user_messages; mod scroll_state; mod selection_popup_common; mod textarea; +pub(crate) use dependency_input_view::DependencyInputView; pub(crate) use feedback_view::FeedbackNoteView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 4ee3aa5f1f4..bdc96ed7440 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -45,6 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SkillDependencyRequestEvent; use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; @@ -88,6 +89,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::DependencyInputView; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -828,6 +830,14 @@ impl ChatWidget { ); } + fn on_skill_dependency_request(&mut self, ev: SkillDependencyRequestEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_skill_dependencies(ev), + |s| s.handle_skill_dependency_request_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); let ev2 = ev.clone(); @@ -1158,6 +1168,19 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_skill_dependency_request_now(&mut self, ev: SkillDependencyRequestEvent) { + self.flush_answer_stream_with_separator(); + + let view = DependencyInputView::new( + ev.id, + ev.skill_name, + ev.dependencies, + self.app_event_tx.clone(), + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1783,6 +1806,7 @@ impl ChatWidget { items.push(UserInput::Skill { name: skill.name.clone(), path: skill.path.clone(), + validate_dependencies: true, }); } } @@ -1903,6 +1927,9 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } + EventMsg::SkillDependencyRequest(ev) => { + self.on_skill_dependency_request(ev); + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), diff --git a/codex-rs/tui2/src/chatwidget/interrupts.rs b/codex-rs/tui2/src/chatwidget/interrupts.rs index dc1e683ea55..3875d711d82 100644 --- a/codex-rs/tui2/src/chatwidget/interrupts.rs +++ b/codex-rs/tui2/src/chatwidget/interrupts.rs @@ -7,6 +7,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::SkillDependencyRequestEvent; use codex_protocol::approvals::ElicitationRequestEvent; use super::ChatWidget; @@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), + SkillDependency(SkillDependencyRequestEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -57,6 +59,10 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + pub(crate) fn push_skill_dependencies(&mut self, ev: SkillDependencyRequestEvent) { + self.queue.push_back(QueuedInterrupt::SkillDependency(ev)); + } + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } @@ -85,6 +91,9 @@ impl InterruptManager { chat.handle_apply_patch_approval_now(id, ev) } QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::SkillDependency(ev) => { + chat.handle_skill_dependency_request_now(ev) + } QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),