mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/connectors_codex_cli_1
This commit is contained in:
@@ -15,11 +15,13 @@ In the codex-rs folder where the rust code lives:
|
||||
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
|
||||
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
|
||||
|
||||
Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
|
||||
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
|
||||
|
||||
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
|
||||
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite.
|
||||
|
||||
Before finalizing a large change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates.
|
||||
|
||||
## TUI style conventions
|
||||
|
||||
See `codex-rs/tui/styles.md`.
|
||||
|
||||
@@ -27,6 +27,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
priority,
|
||||
upgrade: preset.upgrade.as_ref().map(|u| u.into()),
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -77,6 +77,7 @@ async fn models_client_hits_models_endpoint() {
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
danger-unless-trusted
|
||||
@@ -290,7 +290,7 @@ impl Codex {
|
||||
.base_instructions
|
||||
.clone()
|
||||
.or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
|
||||
.unwrap_or_else(|| model_info.base_instructions.clone());
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.model_personality));
|
||||
|
||||
// TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode
|
||||
// to avoid extracting these fields separately and constructing CollaborationMode here.
|
||||
@@ -2502,8 +2502,7 @@ mod handlers {
|
||||
for cwd in cwds {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let enabled_skills = outcome.enabled_skills();
|
||||
let skills_metadata = super::skills_to_info(&enabled_skills);
|
||||
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
skills.push(SkillsListEntry {
|
||||
cwd,
|
||||
skills: skills_metadata,
|
||||
@@ -2756,7 +2755,10 @@ async fn spawn_review_thread(
|
||||
.await;
|
||||
}
|
||||
|
||||
fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
|
||||
fn skills_to_info(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
) -> Vec<ProtocolSkillMetadata> {
|
||||
skills
|
||||
.iter()
|
||||
.map(|skill| ProtocolSkillMetadata {
|
||||
@@ -2776,6 +2778,7 @@ fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
enabled: !disabled_paths.contains(&skill.path),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3830,7 +3833,7 @@ mod tests {
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.base_instructions.clone()),
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
@@ -3905,7 +3908,7 @@ mod tests {
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.base_instructions.clone()),
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
@@ -4164,7 +4167,7 @@ mod tests {
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.base_instructions.clone()),
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
@@ -4268,7 +4271,7 @@ mod tests {
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.base_instructions.clone()),
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::config::types::Notifications;
|
||||
use crate::config::types::OtelConfig;
|
||||
use crate::config::types::OtelConfigToml;
|
||||
use crate::config::types::OtelExporterKind;
|
||||
use crate::config::types::Personality;
|
||||
use crate::config::types::SandboxWorkspaceWrite;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::config::types::ShellEnvironmentPolicyToml;
|
||||
@@ -43,6 +42,7 @@ use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use crate::config_loader::RequirementSource;
|
||||
pub use codex_protocol::config_types::AltScreenMode;
|
||||
pub use codex_protocol::config_types::Personality;
|
||||
pub use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -628,14 +629,6 @@ impl Default for ShellEnvironmentPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Personality {
|
||||
Friendly,
|
||||
#[default]
|
||||
Pragmatic,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -477,6 +477,42 @@ model_instructions_file = "child.txt"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_model_instructions_file_sets_base_instructions() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "").await?;
|
||||
|
||||
let cwd = tmp.path().join("work");
|
||||
tokio::fs::create_dir_all(&cwd).await?;
|
||||
|
||||
let instructions_path = tmp.path().join("instr.md");
|
||||
tokio::fs::write(&instructions_path, "cli override instructions").await?;
|
||||
|
||||
let cli_overrides = vec![(
|
||||
"model_instructions_file".to_string(),
|
||||
TomlValue::String(instructions_path.to_string_lossy().to_string()),
|
||||
)];
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(cli_overrides)
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd),
|
||||
..ConfigOverrides::default()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.base_instructions.as_deref(),
|
||||
Some("cli override instructions")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -86,8 +86,9 @@ impl ContextManager {
|
||||
// This is a coarse lower bound, not a tokenizer-accurate count.
|
||||
pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option<i64> {
|
||||
let model_info = turn_context.client.get_model_info();
|
||||
let base_instructions = model_info.base_instructions.as_str();
|
||||
let base_tokens = i64::try_from(approx_token_count(base_instructions)).unwrap_or(i64::MAX);
|
||||
let personality = turn_context.client.config().model_personality;
|
||||
let base_instructions = model_info.get_model_instructions(personality);
|
||||
let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX);
|
||||
|
||||
let items_tokens = self.items.iter().fold(0i64, |acc, item| {
|
||||
acc + match item {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::openai_models::ApplyPatchToolType;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelInstructionsTemplate;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::PersonalityMessages;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::TruncationMode;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::types::Personality;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -21,7 +25,12 @@ const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md
|
||||
const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md");
|
||||
const GPT_5_2_INSTRUCTIONS: &str = include_str!("../../gpt_5_2_prompt.md");
|
||||
const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md");
|
||||
|
||||
const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt-5.2-codex_prompt.md");
|
||||
const GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE: &str =
|
||||
include_str!("../../templates/model_instructions/gpt-5.2-codex_instructions_template.md");
|
||||
const PERSONALITY_FRIENDLY: &str = include_str!("../../templates/personalities/friendly.md");
|
||||
const PERSONALITY_PRAGMATIC: &str = include_str!("../../templates/personalities/pragmatic.md");
|
||||
|
||||
pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000;
|
||||
|
||||
@@ -45,6 +54,7 @@ macro_rules! model_info {
|
||||
priority: 99,
|
||||
upgrade: None,
|
||||
base_instructions: BASE_INSTRUCTIONS.to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
@@ -87,22 +97,10 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(base_instructions) = &config.base_instructions {
|
||||
model.base_instructions = base_instructions.clone();
|
||||
} else if model.slug.contains("gpt-5.2-codex")
|
||||
&& let Some(personality) = &config.model_personality
|
||||
{
|
||||
let template = include_str!(
|
||||
"../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"
|
||||
);
|
||||
let personality_message = match personality {
|
||||
Personality::Friendly => include_str!("../../templates/personalities/friendly.md"),
|
||||
Personality::Pragmatic => {
|
||||
include_str!("../../templates/personalities/pragmatic.md")
|
||||
}
|
||||
};
|
||||
let instructions = template.replace("{{ personality_message }}", personality_message);
|
||||
model.base_instructions = instructions;
|
||||
model.model_instructions_template = None;
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -205,6 +203,16 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
|
||||
truncation_policy: TruncationPolicyConfig::tokens(10_000),
|
||||
context_window: Some(CONTEXT_WINDOW_272K),
|
||||
supported_reasoning_levels: supported_reasoning_level_low_medium_high_xhigh(),
|
||||
model_instructions_template: Some(ModelInstructionsTemplate {
|
||||
template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(),
|
||||
personality_messages: Some(PersonalityMessages(BTreeMap::from([(
|
||||
Personality::Friendly,
|
||||
PERSONALITY_FRIENDLY.to_string(),
|
||||
), (
|
||||
Personality::Pragmatic,
|
||||
PERSONALITY_PRAGMATIC.to_string(),
|
||||
)]))),
|
||||
}),
|
||||
)
|
||||
} else if slug.starts_with("gpt-5.1-codex-max") {
|
||||
model_info!(
|
||||
|
||||
@@ -48,6 +48,7 @@ mod models_etag_responses;
|
||||
mod otel;
|
||||
mod pending_input;
|
||||
mod permissions_messages;
|
||||
mod personality;
|
||||
mod prompt_caching;
|
||||
mod quota_exceeded;
|
||||
mod read_file;
|
||||
|
||||
@@ -174,6 +174,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
|
||||
priority,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -4,20 +4,31 @@ use core_test_support::load_default_config_for_test;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!(
|
||||
"../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"
|
||||
);
|
||||
const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md");
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_personality_updates_base_instructions() {
|
||||
async fn model_personality_does_not_mutate_base_instructions_without_template() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.model_personality = Some(Personality::Friendly);
|
||||
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config);
|
||||
let expected =
|
||||
BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY);
|
||||
|
||||
assert_eq!(model_info.base_instructions, expected);
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5.1", &config);
|
||||
assert_eq!(
|
||||
model_info.get_model_instructions(config.model_personality),
|
||||
model_info.base_instructions
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn base_instructions_override_disables_personality_template() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.model_personality = Some(Personality::Friendly);
|
||||
config.base_instructions = Some("override instructions".to_string());
|
||||
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config);
|
||||
|
||||
assert_eq!(model_info.base_instructions, "override instructions");
|
||||
assert_eq!(
|
||||
model_info.get_model_instructions(config.model_personality),
|
||||
"override instructions"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
@@ -312,6 +313,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
base_instructions: remote_base.to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
@@ -782,6 +784,7 @@ fn test_remote_model_with_policy(
|
||||
priority,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -65,6 +65,27 @@ pub enum SandboxMode {
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Display,
|
||||
JsonSchema,
|
||||
TS,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum Personality {
|
||||
Friendly,
|
||||
Pragmatic,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
|
||||
)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -7,10 +8,14 @@ use serde::Serialize;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
use tracing::warn;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::config_types::Personality;
|
||||
use crate::config_types::Verbosity;
|
||||
|
||||
const PERSONALITY_PLACEHOLDER: &str = "{{ personality_message }}";
|
||||
|
||||
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -180,6 +185,8 @@ pub struct ModelInfo {
|
||||
pub priority: i32,
|
||||
pub upgrade: Option<ModelInfoUpgrade>,
|
||||
pub base_instructions: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_instructions_template: Option<ModelInstructionsTemplate>,
|
||||
pub supports_reasoning_summaries: bool,
|
||||
pub support_verbosity: bool,
|
||||
pub default_verbosity: Option<Verbosity>,
|
||||
@@ -206,8 +213,49 @@ impl ModelInfo {
|
||||
.map(|context_window| (context_window * 9) / 10)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_model_instructions(&self, personality: Option<Personality>) -> String {
|
||||
if let Some(personality) = personality
|
||||
&& let Some(template) = &self.model_instructions_template
|
||||
&& template.has_personality_placeholder()
|
||||
&& let Some(personality_messages) = &template.personality_messages
|
||||
&& let Some(personality_message) = personality_messages.0.get(&personality)
|
||||
{
|
||||
template
|
||||
.template
|
||||
.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str())
|
||||
} else if let Some(personality) = personality {
|
||||
warn!(
|
||||
model = %self.slug,
|
||||
%personality,
|
||||
"Model personality requested but model_instructions_template is invalid, falling back to base instructions."
|
||||
);
|
||||
self.base_instructions.clone()
|
||||
} else {
|
||||
self.base_instructions.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A strongly-typed template for assembling model instructions. If populated and valid, will override
|
||||
/// base_instructions.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ModelInstructionsTemplate {
|
||||
pub template: String,
|
||||
pub personality_messages: Option<PersonalityMessages>,
|
||||
}
|
||||
|
||||
impl ModelInstructionsTemplate {
|
||||
fn has_personality_placeholder(&self) -> bool {
|
||||
self.template.contains(PERSONALITY_PLACEHOLDER)
|
||||
}
|
||||
}
|
||||
|
||||
// serializes as a dictionary from personality to message
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
pub struct PersonalityMessages(pub BTreeMap<Personality, String>);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ModelInfoUpgrade {
|
||||
pub model: String,
|
||||
@@ -335,3 +383,67 @@ fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> Rea
|
||||
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
|
||||
.unwrap_or(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn test_model(template: Option<ModelInstructionsTemplate>) -> ModelInfo {
|
||||
ModelInfo {
|
||||
slug: "test-model".to_string(),
|
||||
display_name: "Test Model".to_string(),
|
||||
description: None,
|
||||
default_reasoning_level: None,
|
||||
supported_reasoning_levels: vec![],
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility: ModelVisibility::List,
|
||||
supported_in_api: true,
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
base_instructions: "base".to_string(),
|
||||
model_instructions_template: template,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
context_window: None,
|
||||
auto_compact_token_limit: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn personality_messages() -> PersonalityMessages {
|
||||
PersonalityMessages(BTreeMap::from([(
|
||||
Personality::Friendly,
|
||||
"friendly".to_string(),
|
||||
)]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_model_instructions_uses_template_when_placeholder_present() {
|
||||
let model = test_model(Some(ModelInstructionsTemplate {
|
||||
template: "Hello {{ personality_message }}".to_string(),
|
||||
personality_messages: Some(personality_messages()),
|
||||
}));
|
||||
|
||||
let instructions = model.get_model_instructions(Some(Personality::Friendly));
|
||||
|
||||
assert_eq!(instructions, "Hello friendly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_model_instructions_falls_back_when_placeholder_missing() {
|
||||
let model = test_model(Some(ModelInstructionsTemplate {
|
||||
template: "Hello there".to_string(),
|
||||
personality_messages: Some(personality_messages()),
|
||||
}));
|
||||
|
||||
let instructions = model.get_model_instructions(Some(Personality::Friendly));
|
||||
|
||||
assert_eq!(instructions, "base");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2058,6 +2058,7 @@ pub struct SkillMetadata {
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
|
||||
|
||||
@@ -1032,6 +1032,14 @@ impl App {
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenAppLink {
|
||||
title,
|
||||
instructions,
|
||||
url,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_app_link_view(title, instructions, url);
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
@@ -1479,6 +1487,33 @@ impl App {
|
||||
AppEvent::OpenApprovalsPopup => {
|
||||
self.chat_widget.open_approvals_popup();
|
||||
}
|
||||
AppEvent::OpenSkillsList => {
|
||||
self.chat_widget.open_skills_list();
|
||||
}
|
||||
AppEvent::OpenManageSkillsPopup => {
|
||||
self.chat_widget.open_manage_skills_popup();
|
||||
}
|
||||
AppEvent::SetSkillEnabled { path, enabled } => {
|
||||
let edits = [ConfigEdit::SetSkillConfig {
|
||||
path: path.clone(),
|
||||
enabled,
|
||||
}];
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_skill_enabled(path.clone(), enabled);
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to update skill config for {path_display}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenReviewBranchPicker(cwd) => {
|
||||
self.chat_widget.show_review_branch_picker(&cwd).await;
|
||||
}
|
||||
@@ -1488,6 +1523,9 @@ impl App {
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
self.chat_widget.show_review_custom_prompt();
|
||||
}
|
||||
AppEvent::ManageSkillsClosed => {
|
||||
self.chat_widget.handle_manage_skills_closed();
|
||||
}
|
||||
AppEvent::FullScreenApprovalRequest(request) => match request {
|
||||
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
||||
let _ = tui.enter_alt_screen();
|
||||
|
||||
@@ -100,6 +100,13 @@ pub(crate) enum AppEvent {
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
/// Open the app link view in the bottom pane.
|
||||
OpenAppLink {
|
||||
title: String,
|
||||
instructions: String,
|
||||
url: String,
|
||||
},
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
StartCommitAnimation,
|
||||
@@ -221,6 +228,21 @@ pub(crate) enum AppEvent {
|
||||
/// Re-open the approval presets popup.
|
||||
OpenApprovalsPopup,
|
||||
|
||||
/// Open the skills list popup.
|
||||
OpenSkillsList,
|
||||
|
||||
/// Open the skills enable/disable picker.
|
||||
OpenManageSkillsPopup,
|
||||
|
||||
/// Enable or disable a skill by path.
|
||||
SetSkillEnabled {
|
||||
path: PathBuf,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use textwrap::wrap;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::style::user_message_style;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
|
||||
pub(crate) struct AppLinkView {
|
||||
title: String,
|
||||
instructions: String,
|
||||
url: String,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl AppLinkView {
|
||||
pub(crate) fn new(title: String, instructions: String, url: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
instructions,
|
||||
url,
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let usable_width = width.max(1) as usize;
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
lines.push(Line::from(self.title.clone().bold()));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let instructions = self.instructions.trim();
|
||||
if !instructions.is_empty() {
|
||||
for line in wrap(instructions, usable_width) {
|
||||
lines.push(Line::from(line.into_owned()));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
lines.push(Line::from(vec!["Open:".dim()]));
|
||||
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
|
||||
lines.extend(word_wrap_lines(vec![url_line], usable_width));
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for AppLinkView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} = key_event
|
||||
{
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::render::renderable::Renderable for AppLinkView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let content_width = width.saturating_sub(4).max(1);
|
||||
let content_lines = self.content_lines(content_width);
|
||||
content_lines.len() as u16 + 3
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style())
|
||||
.render(area, buf);
|
||||
|
||||
let [content_area, hint_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
|
||||
let inner = content_area.inset(Insets::vh(1, 2));
|
||||
let content_width = inner.width.max(1);
|
||||
let lines = self.content_lines(content_width);
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
|
||||
if hint_area.height > 0 {
|
||||
let hint_area = Rect {
|
||||
x: hint_area.x.saturating_add(2),
|
||||
y: hint_area.y,
|
||||
width: hint_area.width.saturating_sub(2),
|
||||
height: hint_area.height,
|
||||
};
|
||||
hint_line().dim().render(hint_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close".into(),
|
||||
])
|
||||
}
|
||||
@@ -1382,10 +1382,6 @@ impl ChatComposer {
|
||||
(rebuilt, rebuilt_elements)
|
||||
}
|
||||
|
||||
fn skills_enabled(&self) -> bool {
|
||||
self.skills.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||||
self.skills.as_ref()
|
||||
}
|
||||
@@ -2360,13 +2356,11 @@ impl ChatComposer {
|
||||
}
|
||||
_ => {
|
||||
if is_editing_slash_command_name {
|
||||
let skills_enabled = self.skills_enabled();
|
||||
let collaboration_modes_enabled = self.collaboration_modes_enabled;
|
||||
let connectors_enabled = self.connectors_enabled;
|
||||
let mut command_popup = CommandPopup::new(
|
||||
self.custom_prompts.clone(),
|
||||
CommandPopupFlags {
|
||||
skills_enabled,
|
||||
collaboration_modes_enabled,
|
||||
connectors_enabled,
|
||||
},
|
||||
|
||||
@@ -38,7 +38,6 @@ pub(crate) struct CommandPopup {
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct CommandPopupFlags {
|
||||
pub(crate) skills_enabled: bool,
|
||||
pub(crate) collaboration_modes_enabled: bool,
|
||||
pub(crate) connectors_enabled: bool,
|
||||
}
|
||||
@@ -48,7 +47,6 @@ impl CommandPopup {
|
||||
let allow_elevate_sandbox = windows_degraded_sandbox_active();
|
||||
let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills)
|
||||
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
|
||||
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors)
|
||||
@@ -469,7 +467,6 @@ mod tests {
|
||||
let mut popup = CommandPopup::new(
|
||||
Vec::new(),
|
||||
CommandPopupFlags {
|
||||
skills_enabled: false,
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
|
||||
mod app_link_view;
|
||||
mod approval_overlay;
|
||||
mod request_user_input;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
@@ -60,6 +61,7 @@ mod footer;
|
||||
mod list_selection_view;
|
||||
mod prompt_args;
|
||||
mod skill_popup;
|
||||
mod skills_toggle_view;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
@@ -106,10 +108,13 @@ pub(crate) use chat_composer::InputResult;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use experimental_features_view::BetaFeatureItem;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
///
|
||||
|
||||
@@ -14,9 +14,8 @@ use super::selection_popup_common::render_rows_single_line;
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MentionItem {
|
||||
@@ -120,20 +119,34 @@ impl SkillPopup {
|
||||
}
|
||||
|
||||
for (idx, mention) in self.mentions.iter().enumerate() {
|
||||
let display_name = mention.display_name.as_str();
|
||||
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
|
||||
out.push((idx, Some(indices), score));
|
||||
continue;
|
||||
let mut best_match: Option<(Option<Vec<usize>>, i32)> = None;
|
||||
|
||||
if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) {
|
||||
best_match = Some((Some(indices), score));
|
||||
}
|
||||
|
||||
let mut best_score: Option<i32> = None;
|
||||
for term in &mention.search_terms {
|
||||
if term == &mention.display_name {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((_indices, score)) = fuzzy_match(term, filter) {
|
||||
best_score = Some(best_score.map_or(score, |best| best.max(score)));
|
||||
match best_match.as_mut() {
|
||||
Some((best_indices, best_score)) => {
|
||||
if score > *best_score {
|
||||
*best_score = score;
|
||||
*best_indices = None;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
best_match = Some((None, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(score) = best_score {
|
||||
out.push((idx, None, score));
|
||||
|
||||
if let Some((indices, score)) = best_match {
|
||||
out.push((idx, indices, score));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::skills_helpers::match_skill;
|
||||
use crate::skills_helpers::truncate_skill_name;
|
||||
use crate::style::user_message_style;
|
||||
use codex_core::protocol::Op;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows_single_line;
|
||||
|
||||
const SEARCH_PLACEHOLDER: &str = "Type to search skills";
|
||||
const SEARCH_PROMPT_PREFIX: &str = "> ";
|
||||
|
||||
pub(crate) struct SkillsToggleItem {
|
||||
pub name: String,
|
||||
pub skill_name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) struct SkillsToggleView {
|
||||
items: Vec<SkillsToggleItem>,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
header: Box<dyn Renderable>,
|
||||
footer_hint: Line<'static>,
|
||||
search_query: String,
|
||||
filtered_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
impl SkillsToggleView {
|
||||
pub(crate) fn new(items: Vec<SkillsToggleItem>, app_event_tx: AppEventSender) -> Self {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Enable/Disable Skills".bold()));
|
||||
header.push(Line::from(
|
||||
"Turn skills on or off. Your changes are saved automatically.".dim(),
|
||||
));
|
||||
|
||||
let mut view = Self {
|
||||
items,
|
||||
state: ScrollState::new(),
|
||||
complete: false,
|
||||
app_event_tx,
|
||||
header: Box::new(header),
|
||||
footer_hint: skills_toggle_hint_line(),
|
||||
search_query: String::new(),
|
||||
filtered_indices: Vec::new(),
|
||||
};
|
||||
view.apply_filter();
|
||||
view
|
||||
}
|
||||
|
||||
fn visible_len(&self) -> usize {
|
||||
self.filtered_indices.len()
|
||||
}
|
||||
|
||||
fn max_visible_rows(len: usize) -> usize {
|
||||
MAX_POPUP_ROWS.min(len.max(1))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
// Filter + sort while preserving the current selection when possible.
|
||||
let previously_selected = self
|
||||
.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied());
|
||||
|
||||
let filter = self.search_query.trim();
|
||||
if filter.is_empty() {
|
||||
self.filtered_indices = (0..self.items.len()).collect();
|
||||
} else {
|
||||
let mut matches: Vec<(usize, i32)> = Vec::new();
|
||||
for (idx, item) in self.items.iter().enumerate() {
|
||||
let display_name = item.name.as_str();
|
||||
if let Some((_indices, score)) = match_skill(filter, display_name, &item.skill_name)
|
||||
{
|
||||
matches.push((idx, score));
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| {
|
||||
a.1.cmp(&b.1).then_with(|| {
|
||||
let an = self.items[a.0].name.as_str();
|
||||
let bn = self.items[b.0].name.as_str();
|
||||
an.cmp(bn)
|
||||
})
|
||||
});
|
||||
|
||||
self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect();
|
||||
}
|
||||
|
||||
let len = self.filtered_indices.len();
|
||||
self.state.selected_idx = previously_selected
|
||||
.and_then(|actual_idx| {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.position(|idx| *idx == actual_idx)
|
||||
})
|
||||
.or_else(|| (len > 0).then_some(0));
|
||||
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn build_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
self.filtered_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '›' } else { ' ' };
|
||||
let marker = if item.enabled { 'x' } else { ' ' };
|
||||
let item_name = truncate_skill_name(&item.name);
|
||||
let name = format!("{prefix} [{marker}] {item_name}");
|
||||
GenericDisplayRow {
|
||||
name,
|
||||
description: Some(item.description.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let len = self.visible_len();
|
||||
self.state.move_up_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let len = self.visible_len();
|
||||
self.state.move_down_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
}
|
||||
|
||||
fn toggle_selected(&mut self) {
|
||||
let Some(idx) = self.state.selected_idx else {
|
||||
return;
|
||||
};
|
||||
let Some(actual_idx) = self.filtered_indices.get(idx).copied() else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = self.items.get_mut(actual_idx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
item.enabled = !item.enabled;
|
||||
self.app_event_tx.send(AppEvent::SetSkillEnabled {
|
||||
path: item.path.clone(),
|
||||
enabled: item.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
if self.complete {
|
||||
return;
|
||||
}
|
||||
self.complete = true;
|
||||
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills {
|
||||
cwds: Vec::new(),
|
||||
force_reload: true,
|
||||
}));
|
||||
}
|
||||
|
||||
fn rows_width(total_width: u16) -> u16 {
|
||||
total_width.saturating_sub(2)
|
||||
}
|
||||
|
||||
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
|
||||
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for SkillsToggleView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{0010}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^P */ => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{000e}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^N */ => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} => {
|
||||
self.search_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(' '),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.toggle_selected(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
self.search_query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.close();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for SkillsToggleView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let rows = self.build_rows();
|
||||
let rows_height = self.rows_height(&rows);
|
||||
|
||||
let mut height = self.header.desired_height(width.saturating_sub(4));
|
||||
height = height.saturating_add(rows_height + 3);
|
||||
height = height.saturating_add(2);
|
||||
height.saturating_add(1)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve the footer line for the key-hint row.
|
||||
let [content_area, footer_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style())
|
||||
.render(content_area, buf);
|
||||
|
||||
let header_height = self
|
||||
.header
|
||||
.desired_height(content_area.width.saturating_sub(4));
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(content_area.width);
|
||||
let rows_height = self.rows_height(&rows);
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(rows_height),
|
||||
])
|
||||
.areas(content_area.inset(Insets::vh(1, 2)));
|
||||
|
||||
self.header.render(header_area, buf);
|
||||
|
||||
// Render the search prompt as two lines to mimic the composer.
|
||||
if search_area.height >= 2 {
|
||||
let [placeholder_area, input_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
|
||||
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
|
||||
let line = if self.search_query.is_empty() {
|
||||
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
SEARCH_PROMPT_PREFIX.dim(),
|
||||
self.search_query.clone().into(),
|
||||
])
|
||||
};
|
||||
line.render(input_area, buf);
|
||||
} else if search_area.height > 0 {
|
||||
let query_span = if self.search_query.is_empty() {
|
||||
SEARCH_PLACEHOLDER.dim()
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Line::from(query_span).render(search_area, buf);
|
||||
}
|
||||
|
||||
if list_area.height > 0 {
|
||||
let render_area = Rect {
|
||||
x: list_area.x.saturating_sub(2),
|
||||
y: list_area.y,
|
||||
width: rows_width.max(1),
|
||||
height: list_area.height,
|
||||
};
|
||||
render_rows_single_line(
|
||||
render_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
render_area.height as usize,
|
||||
"no matches",
|
||||
);
|
||||
}
|
||||
|
||||
let hint_area = Rect {
|
||||
x: footer_area.x + 2,
|
||||
y: footer_area.y,
|
||||
width: footer_area.width.saturating_sub(2),
|
||||
height: footer_area.height,
|
||||
};
|
||||
self.footer_hint.clone().dim().render(hint_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_toggle_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Char(' ')).into(),
|
||||
" or ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to toggle; ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close".into(),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn render_lines(view: &SkillsToggleView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_basic_popup() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![
|
||||
SkillsToggleItem {
|
||||
name: "Repo Scout".to_string(),
|
||||
skill_name: "repo_scout".to_string(),
|
||||
description: "Summarize the repo layout".to_string(),
|
||||
enabled: true,
|
||||
path: PathBuf::from("/tmp/skills/repo_scout.toml"),
|
||||
},
|
||||
SkillsToggleItem {
|
||||
name: "Changelog Writer".to_string(),
|
||||
skill_name: "changelog_writer".to_string(),
|
||||
description: "Draft release notes".to_string(),
|
||||
enabled: false,
|
||||
path: PathBuf::from("/tmp/skills/changelog_writer.toml"),
|
||||
},
|
||||
];
|
||||
let view = SkillsToggleView::new(items, tx);
|
||||
assert_snapshot!("skills_toggle_basic", render_lines(&view, 72));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/skills_toggle_view.rs
|
||||
assertion_line: 439
|
||||
expression: "render_lines(&view, 72)"
|
||||
---
|
||||
|
||||
Enable/Disable Skills
|
||||
Turn skills on or off. Your changes are saved automatically.
|
||||
|
||||
Type to search skills
|
||||
>
|
||||
› [x] Repo Scout Summarize the repo layout
|
||||
[ ] Changelog Writer Draft release notes
|
||||
|
||||
Press space or enter to toggle; esc to close
|
||||
@@ -73,7 +73,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::SkillsListEntry;
|
||||
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -88,7 +88,6 @@ use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_core::skills::model::SkillInterface;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -177,6 +176,8 @@ use self::agent::spawn_agent;
|
||||
use self::agent::spawn_agent_from_existing;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod skills;
|
||||
use self::skills::find_skill_mentions;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -439,6 +440,8 @@ pub(crate) struct ChatWidget {
|
||||
stream_controller: Option<StreamController>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
suppressed_exec_calls: HashSet<String>,
|
||||
skills_all: Vec<ProtocolSkillMetadata>,
|
||||
skills_initial_state: Option<HashMap<PathBuf, bool>>,
|
||||
last_unified_wait: Option<UnifiedExecWaitState>,
|
||||
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
|
||||
task_complete_pending: bool,
|
||||
@@ -776,11 +779,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_skills(skills);
|
||||
}
|
||||
|
||||
fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
|
||||
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
|
||||
self.set_skills(Some(skills));
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_note(
|
||||
&mut self,
|
||||
category: crate::app_event::FeedbackCategory,
|
||||
@@ -804,6 +802,12 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn open_app_link_view(&mut self, title: String, instructions: String, url: String) {
|
||||
let view = crate::bottom_pane::AppLinkView::new(title, instructions, url);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
|
||||
let params = crate::bottom_pane::feedback_upload_consent_params(
|
||||
self.app_event_tx.clone(),
|
||||
@@ -1901,6 +1905,8 @@ impl ChatWidget {
|
||||
active_cell,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -2022,6 +2028,8 @@ impl ChatWidget {
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -2399,7 +2407,7 @@ impl ChatWidget {
|
||||
self.insert_str("@");
|
||||
}
|
||||
SlashCommand::Skills => {
|
||||
self.insert_str("$");
|
||||
self.open_skills_menu();
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
self.add_status_output();
|
||||
@@ -4599,6 +4607,7 @@ impl ChatWidget {
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
|
||||
for connector in connectors {
|
||||
let connector_label = connectors::connector_display_label(connector);
|
||||
let connector_title = connector_label.clone();
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.connector_id);
|
||||
let mut item = SelectionItem {
|
||||
@@ -4607,28 +4616,39 @@ impl ChatWidget {
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
};
|
||||
if !connector.is_accessible {
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_install_instructions_event(install_url.clone()),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description =
|
||||
Some("Press Enter to view the install link.".to_string());
|
||||
} else {
|
||||
item.actions = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(
|
||||
"Install link unavailable.".to_string(),
|
||||
None,
|
||||
),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some("Install link unavailable.".to_string());
|
||||
}
|
||||
let (selected_label, missing_label, instructions) = if connector.is_accessible {
|
||||
(
|
||||
"Press Enter to view the app link.",
|
||||
"App link unavailable.",
|
||||
"Open this app's connector page in your browser.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Press Enter to view the install link.",
|
||||
"Install link unavailable.",
|
||||
"Install this app in your browser, then reload Codex.",
|
||||
)
|
||||
};
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
let title = connector_title.clone();
|
||||
let instructions = instructions.to_string();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenAppLink {
|
||||
title: title.clone(),
|
||||
instructions: instructions.clone(),
|
||||
url: install_url.clone(),
|
||||
});
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(selected_label.to_string());
|
||||
} else {
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(missing_label.to_string(), None),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(missing_label.to_string());
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
@@ -4636,7 +4656,8 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Apps".to_string()),
|
||||
subtitle: Some(
|
||||
"Browse apps. Select an unconnected app to view its install link.".to_string(),
|
||||
"Use $ to insert an app in the prompt. Browse apps to view connector links."
|
||||
.to_string(),
|
||||
),
|
||||
footer_hint: Some(Self::connectors_popup_hint_line()),
|
||||
items,
|
||||
@@ -4648,7 +4669,7 @@ impl ChatWidget {
|
||||
|
||||
fn connectors_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Use $ to insert apps. Press ".into(),
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close.".into(),
|
||||
])
|
||||
@@ -5280,49 +5301,5 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
});
|
||||
}
|
||||
|
||||
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
|
||||
skills_entries
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| {
|
||||
entry
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillMetadata {
|
||||
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,
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut matches: Vec<SkillMetadata> = Vec::new();
|
||||
for skill in skills {
|
||||
if seen.contains(&skill.name) {
|
||||
continue;
|
||||
}
|
||||
let needle = format!("${}", skill.name);
|
||||
if text.contains(&needle) {
|
||||
seen.insert(skill.name.clone());
|
||||
matches.push(skill.clone());
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::SkillsToggleItem;
|
||||
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_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_core::protocol::SkillsListEntry;
|
||||
use codex_core::skills::model::SkillInterface;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn open_skills_list(&mut self) {
|
||||
self.insert_str("$");
|
||||
}
|
||||
|
||||
pub(crate) fn open_skills_menu(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "List skills".to_string(),
|
||||
description: Some("Tip: press $ to open this list directly.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenSkillsList);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Enable/Disable Skills".to_string(),
|
||||
description: Some("Enable or disable skills.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenManageSkillsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Skills".to_string()),
|
||||
subtitle: Some("Choose an action".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_manage_skills_popup(&mut self) {
|
||||
if self.skills_all.is_empty() {
|
||||
self.add_info_message("No skills available.".to_string(), None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut initial_state = HashMap::new();
|
||||
for skill in &self.skills_all {
|
||||
initial_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
|
||||
}
|
||||
self.skills_initial_state = Some(initial_state);
|
||||
|
||||
let items: Vec<SkillsToggleItem> = self
|
||||
.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;
|
||||
SkillsToggleItem {
|
||||
name: display_name,
|
||||
skill_name: name,
|
||||
description,
|
||||
enabled: skill.enabled,
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn update_skill_enabled(&mut self, path: PathBuf, enabled: bool) {
|
||||
let target = normalize_skill_config_path(&path);
|
||||
for skill in &mut self.skills_all {
|
||||
if normalize_skill_config_path(&skill.path) == target {
|
||||
skill.enabled = enabled;
|
||||
}
|
||||
}
|
||||
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_manage_skills_closed(&mut self) {
|
||||
let Some(initial_state) = self.skills_initial_state.take() else {
|
||||
return;
|
||||
};
|
||||
let mut current_state = HashMap::new();
|
||||
for skill in &self.skills_all {
|
||||
current_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
|
||||
}
|
||||
|
||||
let mut enabled_count = 0;
|
||||
let mut disabled_count = 0;
|
||||
for (path, was_enabled) in initial_state {
|
||||
let Some(is_enabled) = current_state.get(&path) else {
|
||||
continue;
|
||||
};
|
||||
if was_enabled != *is_enabled {
|
||||
if *is_enabled {
|
||||
enabled_count += 1;
|
||||
} else {
|
||||
disabled_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabled_count == 0 && disabled_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.add_info_message(
|
||||
format!("{enabled_count} skills enabled, {disabled_count} skills disabled"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
|
||||
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
|
||||
self.skills_all = skills;
|
||||
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
|
||||
skills_entries
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| entry.skills.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
|
||||
skills
|
||||
.iter()
|
||||
.filter(|skill| skill.enabled)
|
||||
.map(protocol_skill_to_core)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
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,
|
||||
}),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut matches: Vec<SkillMetadata> = Vec::new();
|
||||
for skill in skills {
|
||||
if seen.contains(&skill.name) {
|
||||
continue;
|
||||
}
|
||||
let needle = format!("${}", skill.name);
|
||||
if text.contains(&needle) {
|
||||
seen.insert(skill.name.clone());
|
||||
matches.push(skill.clone());
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
fn normalize_skill_config_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -814,6 +814,8 @@ async fn make_chatwidget_manual(
|
||||
stream_controller: None,
|
||||
running_commands: HashMap::new(),
|
||||
suppressed_exec_calls: HashSet::new(),
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
last_unified_wait: None,
|
||||
unified_exec_wait_streak: None,
|
||||
task_complete_pending: false,
|
||||
|
||||
@@ -1665,14 +1665,6 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_install_instructions_event(install_url: String) -> PrefixedWrappedHistoryCell {
|
||||
let instructions = Line::from("Install this app in your browser, then reload Codex.");
|
||||
let open_label = Line::from(vec!["Open:".dim()]);
|
||||
let url_line = Line::from(vec![install_url.cyan().underlined()]);
|
||||
let text = Text::from(vec![instructions, open_label, url_line]);
|
||||
PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ")
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
// Use a hair space (U+200A) to create a subtle, near-invisible separation
|
||||
// before the text. VS16 is intentionally omitted to keep spacing tighter
|
||||
|
||||
@@ -77,6 +77,7 @@ mod resume_picker;
|
||||
mod selection_list;
|
||||
mod session_log;
|
||||
mod shimmer;
|
||||
mod skills_helpers;
|
||||
mod slash_command;
|
||||
mod status;
|
||||
mod status_indicator_widget;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
|
||||
pub(crate) const SKILL_NAME_TRUNCATE_LEN: usize = 21;
|
||||
|
||||
pub(crate) fn skill_display_name(skill: &SkillMetadata) -> &str {
|
||||
skill
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref())
|
||||
.unwrap_or(&skill.name)
|
||||
}
|
||||
|
||||
pub(crate) fn skill_description(skill: &SkillMetadata) -> &str {
|
||||
skill
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.short_description.as_deref())
|
||||
.or(skill.short_description.as_deref())
|
||||
.unwrap_or(&skill.description)
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_skill_name(name: &str) -> String {
|
||||
truncate_text(name, SKILL_NAME_TRUNCATE_LEN)
|
||||
}
|
||||
|
||||
pub(crate) fn match_skill(
|
||||
filter: &str,
|
||||
display_name: &str,
|
||||
skill_name: &str,
|
||||
) -> Option<(Option<Vec<usize>>, i32)> {
|
||||
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
|
||||
return Some((Some(indices), score));
|
||||
}
|
||||
if display_name != skill_name
|
||||
&& let Some((_indices, score)) = fuzzy_match(skill_name, filter)
|
||||
{
|
||||
return Some((None, score));
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user