[connectors] Support connectors part 2 - slash command and tui (#9728)

- [x] Support `/apps` slash command to browse the apps in tui.
- [x] Support inserting apps to prompt using `$`.
- [x] Lots of simplification/renaming from connectors to apps.
This commit is contained in:
Matthew Zeng 2026-01-28 19:51:58 -08:00 committed by GitHub
parent ecc66f4f52
commit b9cd089d1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2028 additions and 365 deletions

3
codex-rs/Cargo.lock generated
View file

@ -1220,10 +1220,12 @@ dependencies = [
"codex-core",
"codex-git",
"codex-utils-cargo-bin",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tokio",
"urlencoding",
]
[[package]]
@ -1886,6 +1888,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
"codex-cli",
"codex-common",
"codex-core",

View file

@ -1003,6 +1003,8 @@ pub struct AppInfo {
pub name: String,
pub description: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
pub distribution_channel: Option<String>,
pub install_url: Option<String>,
#[serde(default)]
pub is_accessible: bool,
@ -1897,6 +1899,10 @@ pub enum UserInput {
name: String,
path: PathBuf,
},
Mention {
name: String,
path: String,
},
}
impl UserInput {
@ -1912,6 +1918,7 @@ impl UserInput {
UserInput::Image { url } => CoreUserInput::Image { image_url: url },
UserInput::LocalImage { path } => CoreUserInput::LocalImage { path },
UserInput::Skill { name, path } => CoreUserInput::Skill { name, path },
UserInput::Mention { name, path } => CoreUserInput::Mention { name, path },
}
}
}
@ -1929,6 +1936,7 @@ impl From<CoreUserInput> for UserInput {
CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
CoreUserInput::Skill { name, path } => UserInput::Skill { name, path },
CoreUserInput::Mention { name, path } => UserInput::Mention { name, path },
_ => unreachable!("unsupported user input variant"),
}
}
@ -2732,6 +2740,10 @@ mod tests {
name: "skill-creator".to_string(),
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
},
CoreUserInput::Mention {
name: "Demo App".to_string(),
path: "app://demo-app".to_string(),
},
],
});
@ -2754,6 +2766,10 @@ mod tests {
name: "skill-creator".to_string(),
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
},
UserInput::Mention {
name: "Demo App".to_string(),
path: "app://demo-app".to_string(),
},
],
}
);

View file

@ -13,6 +13,7 @@
- [Events](#events)
- [Approvals](#approvals)
- [Skills](#skills)
- [Apps](#apps)
- [Auth endpoints](#auth-endpoints)
## Protocol
@ -296,6 +297,26 @@ Invoke a skill explicitly by including `$<skill-name>` in the text input and add
} } }
```
### Example: Start a turn (invoke an app)
Invoke an app by including `$<app-slug>` in the text input and adding a `mention` input item with the app id in `app://<connector-id>` form.
```json
{ "method": "turn/start", "id": 34, "params": {
"threadId": "thr_123",
"input": [
{ "type": "text", "text": "$demo-app Summarize the latest updates." },
{ "type": "mention", "name": "Demo App", "path": "app://demo-app" }
]
} }
{ "id": 34, "result": { "turn": {
"id": "turn_458",
"status": "inProgress",
"items": [],
"error": null
} } }
```
### Example: Interrupt an active turn
You can cancel a running Turn with `turn/interrupt`.
@ -583,6 +604,57 @@ To enable or disable a skill by path:
}
```
## Apps
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
```json
{ "method": "app/list", "id": 50, "params": {
"cursor": null,
"limit": 50
} }
{ "id": 50, "result": {
"data": [
{
"id": "demo-app",
"name": "Demo App",
"description": "Example connector for documentation.",
"logoUrl": "https://example.com/demo-app.png",
"logoUrlDark": null,
"distributionChannel": null,
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
"isAccessible": true
}
],
"nextCursor": null
} }
```
Invoke an app by inserting `$<app-slug>` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://<connector-id>` path rather than guessing by name.
Example:
```
$demo-app Pull the latest updates from the team.
```
```json
{
"method": "turn/start",
"id": 51,
"params": {
"threadId": "thread-1",
"input": [
{
"type": "text",
"text": "$demo-app Pull the latest updates from the team."
},
{ "type": "mention", "name": "Demo App", "path": "app://demo-app" }
]
}
}
```
## Auth endpoints
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.

View file

@ -13,7 +13,6 @@ use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::AppInfo as ApiAppInfo;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ArchiveConversationParams;
@ -3712,7 +3711,7 @@ impl CodexMessageProcessor {
}
};
if !config.features.enabled(Feature::Connectors) {
if !config.features.enabled(Feature::Apps) {
self.outgoing
.send_response(
request_id,
@ -3775,18 +3774,7 @@ impl CodexMessageProcessor {
}
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end]
.iter()
.cloned()
.map(|connector| ApiAppInfo {
id: connector.connector_id,
name: connector.connector_name,
description: connector.connector_description,
logo_url: connector.logo_url,
install_url: connector.install_url,
is_accessible: connector.is_accessible,
})
.collect();
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())

View file

@ -13,14 +13,13 @@ use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use axum::routing::post;
use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::connectors::ConnectorInfo;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::JsonObject;
@ -71,19 +70,23 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
#[tokio::test]
async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
let connectors = vec![
ConnectorInfo {
connector_id: "alpha".to_string(),
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
},
ConnectorInfo {
connector_id: "beta".to_string(),
connector_name: "beta".to_string(),
connector_description: None,
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
},
@ -127,6 +130,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
},
@ -135,6 +140,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
},
@ -150,19 +157,23 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
#[tokio::test]
async fn list_apps_paginates_results() -> Result<()> {
let connectors = vec![
ConnectorInfo {
connector_id: "alpha".to_string(),
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
},
ConnectorInfo {
connector_id: "beta".to_string(),
connector_name: "beta".to_string(),
connector_description: None,
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
},
@ -206,6 +217,8 @@ async fn list_apps_paginates_results() -> Result<()> {
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
}];
@ -234,6 +247,8 @@ async fn list_apps_paginates_results() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
}];
@ -289,13 +304,13 @@ impl ServerHandler for AppListMcpServer {
}
async fn start_apps_server(
connectors: Vec<ConnectorInfo>,
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
let state = AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
expected_account_id: "account-123".to_string(),
response: json!({ "connectors": connectors }),
response: json!({ "apps": connectors, "next_token": null }),
};
let state = Arc::new(state);
let tools = Arc::new(tools);
@ -313,7 +328,11 @@ async fn start_apps_server(
);
let router = Router::new()
.route("/aip/connectors/list_accessible", post(list_connectors))
.route("/connectors/directory/list", get(list_directory_connectors))
.route(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
@ -324,7 +343,7 @@ async fn start_apps_server(
Ok((format!("http://{addr}"), handle))
}
async fn list_connectors(
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
) -> Result<impl axum::response::IntoResponse, StatusCode> {

View file

@ -17,6 +17,8 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View file

@ -5,13 +5,21 @@ use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use anyhow::Context;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::time::Duration;
/// Make a GET request to the ChatGPT backend API.
pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
config: &Config,
path: String,
) -> anyhow::Result<T> {
chatgpt_get_request_with_timeout(config, path, None).await
}
pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
config: &Config,
path: String,
timeout: Option<Duration>,
) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url;
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
@ -28,48 +36,17 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
});
let response = client
let mut request = client
.get(&url)
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.send()
.await
.context("Failed to send request")?;
if response.status().is_success() {
let result: T = response
.json()
.await
.context("Failed to parse JSON response")?;
Ok(result)
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {status}: {body}")
}
}
pub(crate) async fn chatgpt_post_request<T: DeserializeOwned, P: Serialize>(
config: &Config,
access_token: &str,
account_id: &str,
path: &str,
payload: &P,
) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url;
let client = create_client();
let url = format!("{chatgpt_base_url}{path}");
let response = client
.post(&url)
.bearer_auth(access_token)
.header("chatgpt-account-id", account_id)
.header("Content-Type", "application/json")
.json(payload)
.send()
.await
.context("Failed to send request")?;
.header("Content-Type", "application/json");
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}
let response = request.send().await.context("Failed to send request")?;
if response.status().is_success() {
let result: T = response

View file

@ -1,43 +1,45 @@
use std::collections::HashMap;
use codex_core::config::Config;
use codex_core::features::Feature;
use serde::Deserialize;
use serde::Serialize;
use std::time::Duration;
use crate::chatgpt_client::chatgpt_post_request;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
pub use codex_core::connectors::ConnectorInfo;
pub use codex_core::connectors::AppInfo;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
use codex_core::connectors::merge_connectors;
#[derive(Debug, Serialize)]
struct ListConnectorsRequest {
principals: Vec<Principal>,
}
#[derive(Debug, Serialize)]
struct Principal {
#[serde(rename = "type")]
principal_type: PrincipalType,
id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum PrincipalType {
User,
}
#[derive(Debug, Deserialize)]
struct ListConnectorsResponse {
connectors: Vec<ConnectorInfo>,
struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
if !config.features.enabled(Feature::Connectors) {
#[derive(Debug, Deserialize, Clone)]
struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
let (connectors_result, accessible_result) = tokio::join!(
@ -46,11 +48,12 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInf
);
let connectors = connectors_result?;
let accessible = accessible_result?;
Ok(merge_connectors(connectors, accessible))
let merged = merge_connectors(connectors, accessible);
Ok(filter_disallowed_connectors(merged))
}
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
if !config.features.enabled(Feature::Connectors) {
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
@ -58,56 +61,149 @@ pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<Connecto
let token_data =
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let user_id = token_data
.id_token
.chatgpt_user_id
.as_deref()
.ok_or_else(|| {
anyhow::anyhow!("ChatGPT user ID not available, please re-run `codex login`")
})?;
let account_id = token_data
.id_token
.chatgpt_account_id
.as_deref()
.ok_or_else(|| {
anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
})?;
let principal_id = format!("{user_id}__{account_id}");
let request = ListConnectorsRequest {
principals: vec![Principal {
principal_type: PrincipalType::User,
id: principal_id,
}],
};
let response: ListConnectorsResponse = chatgpt_post_request(
config,
token_data.access_token.as_str(),
account_id,
"/aip/connectors/list_accessible?skip_actions=true&external_logos=true",
&request,
)
.await?;
let mut connectors = response.connectors;
let mut apps = list_directory_connectors(config).await?;
if token_data.id_token.is_workspace_account() {
apps.extend(list_workspace_connectors(config).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.connector_name, &connector.connector_id),
None => connector_install_url(&connector.name, &connector.id),
};
connector.connector_name =
normalize_connector_name(&connector.connector_name, &connector.connector_id);
connector.connector_description =
normalize_connector_value(connector.connector_description.as_deref());
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.connector_name
.cmp(&right.connector_name)
.then_with(|| left.connector_id.cmp(&right.connector_id))
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(connectors)
}
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!("/connectors/directory/list?tier=categorized&token={encoded_token}")
}
None => "/connectors/directory/list?tier=categorized".to_string(),
};
let response: DirectoryListResponse =
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
.await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
config,
"/connectors/directory/list_workspace".to_string(),
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let existing_description_present = existing
.description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !existing_description_present && incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
install_url: None,
is_accessible: false,
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
@ -123,3 +219,87 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_string)
}
const ALLOWED_APPS_SDK_APPS: &[&str] = &["asdk_app_69781557cc1481919cf5e9824fa2e792"];
const DISALLOWED_CONNECTOR_IDS: &[&str] = &["asdk_app_6938a94a61d881918ef32cb999ff937c"];
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
// TODO: Support Apps SDK connectors.
connectors
.into_iter()
.filter(is_connector_allowed)
.collect()
}
fn is_connector_allowed(connector: &AppInfo) -> bool {
let connector_id = connector.id.as_str();
if connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
|| DISALLOWED_CONNECTOR_IDS.contains(&connector_id)
{
return false;
}
if connector_id.starts_with("asdk_app_") {
return ALLOWED_APPS_SDK_APPS.contains(&connector_id);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn app(id: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
}
}
#[test]
fn filters_internal_asdk_connectors() {
let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]);
assert_eq!(filtered, vec![app("alpha")]);
}
#[test]
fn allows_whitelisted_asdk_connectors() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
app("beta"),
]);
assert_eq!(
filtered,
vec![
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
app("beta")
]
);
}
#[test]
fn filters_openai_connectors() {
let filtered = filter_disallowed_connectors(vec![
app("connector_openai_foo"),
app("connector_openai_bar"),
app("gamma"),
]);
assert_eq!(filtered, vec![app("gamma")]);
}
#[test]
fn filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
}
}

View file

@ -144,6 +144,9 @@
"apply_patch_freeform": {
"type": "boolean"
},
"apps": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@ -1165,6 +1168,9 @@
"apply_patch_freeform": {
"type": "boolean"
},
"apps": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},

View file

@ -118,6 +118,10 @@ use crate::mcp::effective_mcp_servers;
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_paths;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
@ -163,6 +167,9 @@ use crate::skills::SkillMetadata;
use crate::skills::SkillsManager;
use crate::skills::build_skill_injections;
use crate::skills::collect_explicit_skill_mentions;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::state::SessionState;
@ -2189,7 +2196,7 @@ impl Session {
let config = self.get_config().await;
let mcp_servers = with_codex_apps_mcp(
mcp_servers,
self.features.enabled(Feature::Connectors),
self.features.enabled(Feature::Apps),
auth.as_ref(),
config.as_ref(),
);
@ -3166,9 +3173,38 @@ pub(crate) async fn run_turn(
.await,
);
let (skill_name_counts, skill_name_counts_lower) = skills_outcome.as_ref().map_or_else(
|| (HashMap::new(), HashMap::new()),
|outcome| build_skill_name_counts(&outcome.skills, &outcome.disabled_paths),
);
let connector_slug_counts = if turn_context.client.config().features.enabled(Feature::Apps) {
let mcp_tools = match sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.or_cancel(&cancellation_token)
.await
{
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
};
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
build_connector_slug_counts(&connectors)
} else {
HashMap::new()
};
let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| {
collect_explicit_skill_mentions(&input, &outcome.skills, &outcome.disabled_paths)
collect_explicit_skill_mentions(
&input,
&outcome.skills,
&outcome.disabled_paths,
&skill_name_counts,
&connector_slug_counts,
)
});
let explicit_app_paths = collect_explicit_app_paths(&input);
maybe_prompt_and_install_mcp_dependencies(
sess.as_ref(),
@ -3234,12 +3270,17 @@ pub(crate) async fn run_turn(
})
.map(|user_message| user_message.message())
.collect::<Vec<String>>();
let tool_selection = SamplingRequestToolSelection {
explicit_app_paths: &explicit_app_paths,
skill_name_counts_lower: &skill_name_counts_lower,
};
match run_sampling_request(
Arc::clone(&sess),
Arc::clone(&turn_context),
Arc::clone(&turn_diff_tracker),
&mut client_session,
sampling_request_input,
tool_selection,
cancellation_token.child_token(),
)
.await
@ -3314,40 +3355,79 @@ async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>)
}
fn filter_connectors_for_input(
connectors: Vec<connectors::ConnectorInfo>,
connectors: Vec<connectors::AppInfo>,
input: &[ResponseItem],
) -> Vec<connectors::ConnectorInfo> {
explicit_app_paths: &[String],
skill_name_counts_lower: &HashMap<String, usize>,
) -> Vec<connectors::AppInfo> {
let user_messages = collect_user_messages(input);
if user_messages.is_empty() {
if user_messages.is_empty() && explicit_app_paths.is_empty() {
return Vec::new();
}
let mentions = collect_tool_mentions_from_messages(&user_messages);
let mention_names_lower = mentions
.plain_names
.iter()
.map(|name| name.to_ascii_lowercase())
.collect::<HashSet<String>>();
let connector_slug_counts = build_connector_slug_counts(&connectors);
let mut allowed_connector_ids: HashSet<String> = HashSet::new();
for path in explicit_app_paths
.iter()
.chain(mentions.paths.iter())
.filter(|path| tool_kind_for_path(path) == ToolMentionKind::App)
{
if let Some(connector_id) = app_id_from_path(path) {
allowed_connector_ids.insert(connector_id.to_string());
}
}
connectors
.into_iter()
.filter(|connector| connector_inserted_in_messages(connector, &user_messages))
.filter(|connector| {
connector_inserted_in_messages(
connector,
&mention_names_lower,
&allowed_connector_ids,
&connector_slug_counts,
skill_name_counts_lower,
)
})
.collect()
}
fn connector_inserted_in_messages(
connector: &connectors::ConnectorInfo,
user_messages: &[String],
connector: &connectors::AppInfo,
mention_names_lower: &HashSet<String>,
allowed_connector_ids: &HashSet<String>,
connector_slug_counts: &HashMap<String, usize>,
skill_name_counts_lower: &HashMap<String, usize>,
) -> bool {
let label = connectors::connector_display_label(connector);
let needle = label.to_lowercase();
let legacy = format!("{label} connector").to_lowercase();
user_messages.iter().any(|message| {
let message = message.to_lowercase();
message.contains(&needle) || message.contains(&legacy)
})
if allowed_connector_ids.contains(&connector.id) {
return true;
}
let mention_slug = connectors::connector_mention_slug(connector);
let connector_count = connector_slug_counts
.get(&mention_slug)
.copied()
.unwrap_or(0);
let skill_count = skill_name_counts_lower
.get(&mention_slug)
.copied()
.unwrap_or(0);
connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug)
}
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
connectors: &[connectors::ConnectorInfo],
connectors: &[connectors::AppInfo],
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.connector_id.as_str())
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {
@ -3367,6 +3447,11 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op
tool.connector_id.as_deref()
}
struct SamplingRequestToolSelection<'a> {
explicit_app_paths: &'a [String],
skill_name_counts_lower: &'a HashMap<String, usize>,
}
#[instrument(level = "trace",
skip_all,
fields(
@ -3381,6 +3466,7 @@ async fn run_sampling_request(
turn_diff_tracker: SharedTurnDiffTracker,
client_session: &mut ModelClientSession,
input: Vec<ResponseItem>,
tool_selection: SamplingRequestToolSelection<'_>,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let mut mcp_tools = sess
@ -3391,14 +3477,14 @@ async fn run_sampling_request(
.list_all_tools()
.or_cancel(&cancellation_token)
.await?;
let connectors_for_tools = if turn_context
.client
.config()
.features
.enabled(Feature::Connectors)
{
let connectors_for_tools = if turn_context.client.config().features.enabled(Feature::Apps) {
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
Some(filter_connectors_for_input(connectors, &input))
Some(filter_connectors_for_input(
connectors,
&input,
tool_selection.explicit_app_paths,
tool_selection.skill_name_counts_lower,
))
} else {
None
};
@ -3822,6 +3908,7 @@ mod tests {
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@ -3843,6 +3930,30 @@ mod tests {
expects_apply_patch_instructions: bool,
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
}
}
fn make_connector(id: &str, name: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
name: name.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: true,
}
}
#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
@ -3909,6 +4020,43 @@ mod tests {
}
}
#[test]
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
let connectors = vec![
make_connector("one", "Foo Bar"),
make_connector("two", "Foo-Bar"),
];
let input = vec![user_message("use $foo-bar")];
let explicit_app_paths = Vec::new();
let skill_name_counts_lower = HashMap::new();
let selected = filter_connectors_for_input(
connectors,
&input,
&explicit_app_paths,
&skill_name_counts_lower,
);
assert_eq!(selected, Vec::new());
}
#[test]
fn filter_connectors_for_input_skips_when_skill_name_conflicts() {
let connectors = vec![make_connector("one", "Todoist")];
let input = vec![user_message("use $todoist")];
let explicit_app_paths = Vec::new();
let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]);
let selected = filter_connectors_for_input(
connectors,
&input,
&explicit_app_paths,
&skill_name_counts_lower,
);
assert_eq!(selected, Vec::new());
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;

View file

@ -3,9 +3,8 @@ use std::env;
use std::path::PathBuf;
use async_channel::unbounded;
pub use codex_app_server_protocol::AppInfo;
use codex_protocol::protocol::SandboxPolicy;
use serde::Deserialize;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use crate::AuthManager;
@ -15,28 +14,13 @@ use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
use crate::mcp_connection_manager::McpConnectionManager;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectorInfo {
#[serde(rename = "id")]
pub connector_id: String,
#[serde(rename = "name")]
pub connector_name: String,
#[serde(default, rename = "description")]
pub connector_description: Option<String>,
#[serde(default, rename = "logo_url")]
pub logo_url: Option<String>,
#[serde(default, rename = "install_url")]
pub install_url: Option<String>,
#[serde(default)]
pub is_accessible: bool,
}
pub async fn list_accessible_connectors_from_mcp_tools(
config: &Config,
) -> anyhow::Result<Vec<ConnectorInfo>> {
if !config.features.enabled(Feature::Connectors) {
) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
@ -72,6 +56,13 @@ pub async fn list_accessible_connectors_from_mcp_tools(
)
.await;
if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
mcp_connection_manager
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
.await;
}
let tools = mcp_connection_manager.list_all_tools().await;
cancel_token.cancel();
@ -86,13 +77,17 @@ fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
)
}
pub fn connector_display_label(connector: &ConnectorInfo) -> String {
format_connector_label(&connector.connector_name, &connector.connector_id)
pub fn connector_display_label(connector: &AppInfo) -> String {
format_connector_label(&connector.name, &connector.id)
}
pub fn connector_mention_slug(connector: &AppInfo) -> String {
connector_name_slug(&connector_display_label(connector))
}
pub(crate) fn accessible_connectors_from_mcp_tools(
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
) -> Vec<ConnectorInfo> {
) -> Vec<AppInfo> {
let tools = mcp_tools.values().filter_map(|tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return None;
@ -105,34 +100,37 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
}
pub fn merge_connectors(
connectors: Vec<ConnectorInfo>,
accessible_connectors: Vec<ConnectorInfo>,
) -> Vec<ConnectorInfo> {
let mut merged: HashMap<String, ConnectorInfo> = connectors
connectors: Vec<AppInfo>,
accessible_connectors: Vec<AppInfo>,
) -> Vec<AppInfo> {
let mut merged: HashMap<String, AppInfo> = connectors
.into_iter()
.map(|mut connector| {
connector.is_accessible = false;
(connector.connector_id.clone(), connector)
(connector.id.clone(), connector)
})
.collect();
for mut connector in accessible_connectors {
connector.is_accessible = true;
let connector_id = connector.connector_id.clone();
let connector_id = connector.id.clone();
if let Some(existing) = merged.get_mut(&connector_id) {
existing.is_accessible = true;
if existing.connector_name == existing.connector_id
&& connector.connector_name != connector.connector_id
{
existing.connector_name = connector.connector_name;
if existing.name == existing.id && connector.name != connector.id {
existing.name = connector.name;
}
if existing.connector_description.is_none() && connector.connector_description.is_some()
{
existing.connector_description = connector.connector_description;
if existing.description.is_none() && connector.description.is_some() {
existing.description = connector.description;
}
if existing.logo_url.is_none() && connector.logo_url.is_some() {
existing.logo_url = connector.logo_url;
}
if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() {
existing.logo_url_dark = connector.logo_url_dark;
}
if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() {
existing.distribution_channel = connector.distribution_channel;
}
} else {
merged.insert(connector_id, connector);
}
@ -141,23 +139,20 @@ pub fn merge_connectors(
let mut merged = merged.into_values().collect::<Vec<_>>();
for connector in &mut merged {
if connector.install_url.is_none() {
connector.install_url = Some(connector_install_url(
&connector.connector_name,
&connector.connector_id,
));
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
}
}
merged.sort_by(|left, right| {
right
.is_accessible
.cmp(&left.is_accessible)
.then_with(|| left.connector_name.cmp(&right.connector_name))
.then_with(|| left.connector_id.cmp(&right.connector_id))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
merged
}
fn collect_accessible_connectors<I>(tools: I) -> Vec<ConnectorInfo>
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>)>,
{
@ -172,14 +167,16 @@ where
connectors.insert(connector_id, connector_name);
}
}
let mut accessible: Vec<ConnectorInfo> = connectors
let mut accessible: Vec<AppInfo> = connectors
.into_iter()
.map(|(connector_id, connector_name)| ConnectorInfo {
install_url: Some(connector_install_url(&connector_name, &connector_id)),
connector_id,
connector_name,
connector_description: None,
.map(|(connector_id, connector_name)| AppInfo {
id: connector_id.clone(),
name: connector_name.clone(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some(connector_install_url(&connector_name, &connector_id)),
is_accessible: true,
})
.collect();
@ -187,8 +184,8 @@ where
right
.is_accessible
.cmp(&left.is_accessible)
.then_with(|| left.connector_name.cmp(&right.connector_name))
.then_with(|| left.connector_id.cmp(&right.connector_id))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
accessible
}
@ -205,7 +202,7 @@ pub fn connector_install_url(name: &str, connector_id: &str) -> String {
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
fn connector_name_slug(name: &str) -> String {
pub fn connector_name_slug(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {

View file

@ -111,8 +111,8 @@ pub enum Feature {
EnableRequestCompression,
/// Enable collab tools.
Collab,
/// Enable connectors (apps).
Connectors,
/// Enable apps.
Apps,
/// Allow prompting and installing missing MCP dependencies.
SkillMcpDependencyInstall,
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
@ -518,9 +518,13 @@ pub const FEATURES: &[FeatureSpec] = &[
default_enabled: false,
},
FeatureSpec {
id: Feature::Connectors,
key: "connectors",
stage: Stage::UnderDevelopment,
id: Feature::Apps,
key: "apps",
stage: Stage::Experimental {
name: "Apps",
menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.",
announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!",
},
default_enabled: false,
},
FeatureSpec {

View file

@ -9,6 +9,10 @@ struct Alias {
}
const ALIASES: &[Alias] = &[
Alias {
legacy_key: "connectors",
feature: Feature::Apps,
},
Alias {
legacy_key: "enable_experimental_windows_sandbox",
feature: Feature::WindowsSandbox,

View file

@ -42,6 +42,7 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
pub use mcp_connection_manager::SandboxState;
mod mcp_tool_call;
mod mentions;
mod message_history;
mod model_provider_info;
pub mod parse_command;

View file

@ -6,6 +6,7 @@ pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::time::Duration;
use async_channel::unbounded;
use codex_protocol::protocol::McpListToolsResponseEvent;
@ -25,7 +26,7 @@ use crate::mcp_connection_manager::SandboxState;
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps_mcp";
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
@ -97,7 +98,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: None,
startup_timeout_sec: Some(Duration::from_secs(30)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
@ -128,7 +129,7 @@ pub(crate) fn effective_mcp_servers(
) -> HashMap<String, McpServerConfig> {
with_codex_apps_mcp(
config.mcp_servers.get().clone(),
config.features.enabled(Feature::Connectors),
config.features.enabled(Feature::Apps),
auth,
config,
)

View file

@ -14,6 +14,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::auth::McpAuthStatusEntry;
use anyhow::Context;
use anyhow::Result;
@ -436,13 +437,33 @@ impl McpConnectionManager {
.await
}
pub(crate) async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool {
let Some(async_managed_client) = self.clients.get(server_name) else {
return false;
};
match tokio::time::timeout(timeout, async_managed_client.client()).await {
Ok(Ok(_)) => true,
Ok(Err(_)) | Err(_) => false,
}
}
/// Returns a single map that contains all tools. Each key is the
/// fully-qualified name for the tool.
#[instrument(level = "trace", skip_all)]
pub async fn list_all_tools(&self) -> HashMap<String, ToolInfo> {
let mut tools = HashMap::new();
for managed_client in self.clients.values() {
if let Ok(client) = managed_client.client().await {
for (server_name, managed_client) in &self.clients {
let client = if server_name == CODEX_APPS_MCP_SERVER_NAME {
// Avoid blocking on codex_apps_mcp startup; use tools only when ready.
match managed_client.client.clone().now_or_never() {
Some(Ok(client)) => Some(client),
_ => None,
}
} else {
managed_client.client().await.ok()
};
if let Some(client) = client {
tools.extend(qualify_tools(filter_tools(
client.tools,
client.tool_filter,

View file

@ -0,0 +1,64 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use codex_protocol::user_input::UserInput;
use crate::connectors;
use crate::skills::SkillMetadata;
use crate::skills::injection::extract_tool_mentions;
pub(crate) struct CollectedToolMentions {
pub(crate) plain_names: HashSet<String>,
pub(crate) paths: HashSet<String>,
}
pub(crate) fn collect_tool_mentions_from_messages(messages: &[String]) -> CollectedToolMentions {
let mut plain_names = HashSet::new();
let mut paths = HashSet::new();
for message in messages {
let mentions = extract_tool_mentions(message);
plain_names.extend(mentions.plain_names().map(str::to_string));
paths.extend(mentions.paths().map(str::to_string));
}
CollectedToolMentions { plain_names, paths }
}
pub(crate) fn collect_explicit_app_paths(input: &[UserInput]) -> Vec<String> {
input
.iter()
.filter_map(|item| match item {
UserInput::Mention { path, .. } => Some(path.clone()),
_ => None,
})
.collect()
}
pub(crate) fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
) -> (HashMap<String, usize>, HashMap<String, usize>) {
let mut exact_counts: HashMap<String, usize> = HashMap::new();
let mut lower_counts: HashMap<String, usize> = HashMap::new();
for skill in skills {
if disabled_paths.contains(&skill.path) {
continue;
}
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;
*lower_counts
.entry(skill.name.to_ascii_lowercase())
.or_insert(0) += 1;
}
(exact_counts, lower_counts)
}
pub(crate) fn build_connector_slug_counts(
connectors: &[connectors::AppInfo],
) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::new();
for connector in connectors {
let slug = connectors::connector_mention_slug(connector);
*counts.entry(slug).or_insert(0) += 1;
}
counts
}

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
@ -67,7 +68,8 @@ fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata,
/// Collect explicitly mentioned skills from `$name` text mentions.
///
/// Text inputs are scanned once to extract `$skill-name` tokens, then we iterate `skills`
/// in their existing order to preserve prior ordering semantics.
/// in their existing order to preserve prior ordering semantics. Explicit links are
/// resolved by path and plain names are only used when the match is unambiguous.
///
/// Complexity: `O(S + T + N_t * S)` time, `O(S)` space, where:
/// `S` = number of skills, `T` = total text length, `N_t` = number of text inputs.
@ -75,17 +77,24 @@ pub(crate) fn collect_explicit_skill_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
skill_name_counts: &HashMap<String, usize>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let selection_context = SkillSelectionContext {
skills,
disabled_paths,
skill_name_counts,
connector_slug_counts,
};
let mut selected: Vec<SkillMetadata> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
for input in inputs {
if let UserInput::Text { text, .. } = input {
let mentioned_names = extract_skill_mentions(text);
let mentioned_names = extract_tool_mentions(text);
select_skills_from_mentions(
skills,
disabled_paths,
&selection_context,
&mentioned_names,
&mut seen_names,
&mut seen_paths,
@ -97,36 +106,95 @@ pub(crate) fn collect_explicit_skill_mentions(
selected
}
struct SkillMentions<'a> {
names: HashSet<&'a str>,
paths: HashSet<&'a str>,
struct SkillSelectionContext<'a> {
skills: &'a [SkillMetadata],
disabled_paths: &'a HashSet<PathBuf>,
skill_name_counts: &'a HashMap<String, usize>,
connector_slug_counts: &'a HashMap<String, usize>,
}
impl<'a> SkillMentions<'a> {
pub(crate) struct ToolMentions<'a> {
names: HashSet<&'a str>,
paths: HashSet<&'a str>,
plain_names: HashSet<&'a str>,
}
impl<'a> ToolMentions<'a> {
fn is_empty(&self) -> bool {
self.names.is_empty() && self.paths.is_empty()
}
pub(crate) fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
self.plain_names.iter().copied()
}
pub(crate) fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
self.paths.iter().copied()
}
}
/// Extract `$skill-name` mentions from a single text input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ToolMentionKind {
App,
Mcp,
Skill,
Other,
}
const APP_PATH_PREFIX: &str = "app://";
const MCP_PATH_PREFIX: &str = "mcp://";
const SKILL_PATH_PREFIX: &str = "skill://";
const SKILL_FILENAME: &str = "SKILL.md";
pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind {
if path.starts_with(APP_PATH_PREFIX) {
ToolMentionKind::App
} else if path.starts_with(MCP_PATH_PREFIX) {
ToolMentionKind::Mcp
} else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) {
ToolMentionKind::Skill
} else {
ToolMentionKind::Other
}
}
fn is_skill_filename(path: &str) -> bool {
let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
}
pub(crate) fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix(APP_PATH_PREFIX)
.filter(|value| !value.is_empty())
}
pub(crate) fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
}
/// Extract `$tool-name` mentions from a single text input.
///
/// Supports explicit resource links in the form `[$skill-name](resource path)`. When a
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
/// resource path is present, it is captured for exact path matching while also tracking
/// the name for fallback matching.
fn extract_skill_mentions(text: &str) -> SkillMentions<'_> {
pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
let text_bytes = text.as_bytes();
let mut mentioned_names: HashSet<&str> = HashSet::new();
let mut mentioned_paths: HashSet<&str> = HashSet::new();
let mut plain_names: HashSet<&str> = HashSet::new();
let mut index = 0;
while index < text_bytes.len() {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_skill_mention(text, text_bytes, index)
parse_linked_tool_mention(text, text_bytes, index)
{
if !is_common_env_var(name) {
mentioned_names.insert(name);
let kind = tool_kind_for_path(path);
if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) {
mentioned_names.insert(name);
}
mentioned_paths.insert(path);
}
index = end_index;
@ -143,14 +211,14 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> {
index += 1;
continue;
};
if !is_skill_name_char(*first_name_byte) {
if !is_mention_name_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_skill_name_char(*next_byte)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
@ -158,21 +226,22 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> {
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
mentioned_names.insert(name);
plain_names.insert(name);
}
index = name_end;
}
SkillMentions {
ToolMentions {
names: mentioned_names,
paths: mentioned_paths,
plain_names,
}
}
/// Select mentioned skills while preserving the order of `skills`.
fn select_skills_from_mentions(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
mentions: &SkillMentions<'_>,
selection_context: &SkillSelectionContext<'_>,
mentions: &ToolMentions<'_>,
seen_names: &mut HashSet<String>,
seen_paths: &mut HashSet<PathBuf>,
selected: &mut Vec<SkillMetadata>,
@ -181,32 +250,65 @@ fn select_skills_from_mentions(
return;
}
for skill in skills {
if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) {
let mention_skill_paths: HashSet<&str> = mentions
.paths()
.filter(|path| {
!matches!(
tool_kind_for_path(path),
ToolMentionKind::App | ToolMentionKind::Mcp
)
})
.map(normalize_skill_path)
.collect();
for skill in selection_context.skills {
if selection_context.disabled_paths.contains(&skill.path)
|| seen_paths.contains(&skill.path)
{
continue;
}
let path_str = skill.path.to_string_lossy();
if mentions.paths.contains(path_str.as_ref()) {
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
}
for skill in skills {
if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) {
for skill in selection_context.skills {
if selection_context.disabled_paths.contains(&skill.path)
|| seen_paths.contains(&skill.path)
{
continue;
}
if mentions.names.contains(skill.name.as_str()) && seen_names.insert(skill.name.clone()) {
if !mentions.plain_names.contains(skill.name.as_str()) {
continue;
}
let skill_count = selection_context
.skill_name_counts
.get(skill.name.as_str())
.copied()
.unwrap_or(0);
let connector_count = selection_context
.connector_slug_counts
.get(&skill.name.to_ascii_lowercase())
.copied()
.unwrap_or(0);
if skill_count != 1 || connector_count != 0 {
continue;
}
if seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path.clone());
selected.push(skill.clone());
}
}
}
fn parse_linked_skill_mention<'a>(
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
@ -218,13 +320,13 @@ fn parse_linked_skill_mention<'a>(
let name_start = dollar_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_skill_name_char(*first_name_byte) {
if !is_mention_name_char(*first_name_byte) {
return None;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_skill_name_char(*next_byte)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
@ -304,7 +406,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
let after_index = name_start + skill_bytes.len();
let after = text_bytes.get(after_index).copied();
if after.is_none_or(|b| !is_skill_name_char(b)) {
if after.is_none_or(|b| !is_mention_name_char(b)) {
return true;
}
}
@ -312,7 +414,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
false
}
fn is_skill_name_char(byte: u8) -> bool {
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
@ -320,6 +422,7 @@ fn is_skill_name_char(byte: u8) -> bool {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
fn make_skill(name: &str, path: &str) -> SkillMetadata {
@ -339,11 +442,41 @@ mod tests {
}
fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) {
let mentions = extract_skill_mentions(text);
let mentions = extract_tool_mentions(text);
assert_eq!(mentions.names, set(expected_names));
assert_eq!(mentions.paths, set(expected_paths));
}
fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for skill in skills {
if disabled_paths.contains(&skill.path) {
continue;
}
*counts.entry(skill.name.clone()).or_insert(0) += 1;
}
counts
}
fn collect_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
connector_slug_counts: &HashMap<String, usize>,
) -> Vec<SkillMetadata> {
let skill_name_counts = build_skill_name_counts(skills, disabled_paths);
collect_explicit_skill_mentions(
inputs,
skills,
disabled_paths,
&skill_name_counts,
connector_slug_counts,
)
}
#[test]
fn text_mentions_skill_requires_exact_boundary() {
assert_eq!(
@ -386,7 +519,7 @@ mod tests {
}
#[test]
fn extract_skill_mentions_handles_plain_and_linked_mentions() {
fn extract_tool_mentions_handles_plain_and_linked_mentions() {
assert_mentions(
"use $alpha and [$beta](/tmp/beta)",
&["alpha", "beta"],
@ -395,26 +528,26 @@ mod tests {
}
#[test]
fn extract_skill_mentions_skips_common_env_vars() {
fn extract_tool_mentions_skips_common_env_vars() {
assert_mentions("use $PATH and $alpha", &["alpha"], &[]);
assert_mentions("use [$HOME](/tmp/skill)", &[], &[]);
assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]);
}
#[test]
fn extract_skill_mentions_requires_link_syntax() {
fn extract_tool_mentions_requires_link_syntax() {
assert_mentions("[beta](/tmp/beta)", &[], &[]);
assert_mentions("[$beta] /tmp/beta", &["beta"], &[]);
assert_mentions("[$beta]()", &["beta"], &[]);
}
#[test]
fn extract_skill_mentions_trims_linked_paths_and_allows_spacing() {
fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() {
assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]);
}
#[test]
fn extract_skill_mentions_stops_at_non_name_chars() {
fn extract_tool_mentions_stops_at_non_name_chars() {
assert_mentions(
"use $alpha.skill and $beta_extra",
&["alpha", "beta_extra"],
@ -431,8 +564,9 @@ mod tests {
text: "first $alpha-skill then $beta-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
// Text scanning should not change the previous selection ordering semantics.
assert_eq!(selected, vec![beta, alpha]);
@ -453,8 +587,9 @@ mod tests {
path: PathBuf::from("/tmp/beta"),
},
];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
}
@ -467,25 +602,27 @@ mod tests {
text: "use [$alpha-skill](/tmp/alpha) and [$alpha-skill](/tmp/alpha)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
}
#[test]
fn collect_explicit_skill_mentions_dedupes_by_name() {
fn collect_explicit_skill_mentions_skips_ambiguous_name() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha.clone(), beta];
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: "use $demo-skill and again $demo-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
assert_eq!(selected, Vec::new());
}
#[test]
@ -497,26 +634,58 @@ mod tests {
text: "use $demo-skill and [$demo-skill](/tmp/beta)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![beta]);
}
#[test]
fn collect_explicit_skill_mentions_falls_back_when_linked_path_disabled() {
fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![UserInput::Text {
text: "use $alpha-skill".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha.clone()];
let inputs = vec![UserInput::Text {
text: "use [$alpha-skill](/tmp/alpha)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
}
#[test]
fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha, beta.clone()];
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: "use [$demo-skill](/tmp/alpha)".to_string(),
text_elements: Vec::new(),
}];
let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]);
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &disabled);
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
assert_eq!(selected, vec![beta]);
assert_eq!(selected, Vec::new());
}
#[test]
@ -528,24 +697,41 @@ mod tests {
text: "use [$demo-skill](/tmp/beta)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![beta]);
}
#[test]
fn collect_explicit_skill_mentions_falls_back_to_name_when_path_missing() {
fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let beta = make_skill("demo-skill", "/tmp/beta");
let skills = vec![alpha.clone(), beta];
let skills = vec![alpha, beta];
let inputs = vec![UserInput::Text {
text: "use [$demo-skill](/tmp/missing)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new());
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() {
let alpha = make_skill("demo-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![UserInput::Text {
text: "use [$demo-skill](/tmp/missing)".to_string(),
text_elements: Vec::new(),
}];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
}

View file

@ -42,6 +42,15 @@ impl IdTokenInfo {
PlanType::Unknown(s) => s.clone(),
})
}
pub fn is_workspace_account(&self) -> bool {
matches!(
self.chatgpt_plan_type,
Some(PlanType::Known(
KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu
))
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -140,6 +149,7 @@ where
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde::Serialize;
#[test]
@ -200,4 +210,19 @@ mod tests {
assert!(info.email.is_none());
assert!(info.get_chatgpt_plan_type().is_none());
}
#[test]
fn workspace_account_detection_matches_workspace_plans() {
let workspace = IdTokenInfo {
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)),
..IdTokenInfo::default()
};
assert_eq!(workspace.is_workspace_account(), true);
let personal = IdTokenInfo {
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
..IdTokenInfo::default()
};
assert_eq!(personal.is_workspace_account(), false);
}
}

View file

@ -82,6 +82,15 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc
- `EventMsg::TurnComplete` Contains a `response_id` bookmark for last `response_id` executed by the turn. This can be used to continue the turn at a later point in time, perhaps with additional user input.
- `EventMsg::ListSkillsResponse` Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`)
### UserInput items
`Op::UserTurn` content items can include:
- `text` Plain text plus optional UI text elements.
- `image` / `local_image` Image inputs.
- `skill` Explicit skill selection (`name`, `path` to `SKILL.md`).
- `mention` Explicit app/connector selection (`name`, `path` in `app://{connector_id}` form).
Note: For v1 wire compatibility, `EventMsg::TurnStarted` and `EventMsg::TurnComplete` serialize as `task_started` / `task_complete`. The deserializer accepts both `task_*` and `turn_*` tags.
The `response_id` returned from each turn matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work.

View file

@ -674,7 +674,7 @@ impl From<Vec<UserInput>> for ResponseInputItem {
image_index += 1;
local_image_content_items_with_label_number(&path, Some(image_index))
}
UserInput::Skill { .. } => Vec::new(), // Skill bodies are injected later in core
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
})
.collect::<Vec<ContentItem>>(),
}

View file

@ -29,6 +29,8 @@ pub enum UserInput {
name: String,
path: std::path::PathBuf,
},
/// Explicit mention selected by the user (name + app://connector id).
Mention { name: String, path: String },
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)]

View file

@ -30,6 +30,7 @@ codex-ansi-escape = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-backend-client = { workspace = true }
codex-chatgpt = { workspace = true }
codex-common = { workspace = true, features = [
"cli",
"elapsed",

View file

@ -1508,6 +1508,21 @@ impl App {
));
tui.frame_requester().schedule_frame();
}
AppEvent::OpenAppLink {
title,
description,
instructions,
url,
is_installed,
} => {
self.chat_widget.open_app_link_view(
title,
description,
instructions,
url,
is_installed,
);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@ -1517,6 +1532,9 @@ impl App {
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
AppEvent::ConnectorsLoaded(result) => {
self.chat_widget.on_connectors_loaded(result);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
}

View file

@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_chatgpt::connectors::AppInfo;
use codex_common::approval_presets::ApprovalPreset;
use codex_core::protocol::Event;
use codex_core::protocol::RateLimitSnapshot;
@ -40,6 +41,11 @@ pub(crate) enum WindowsSandboxFallbackReason {
ElevationFailed,
}
#[derive(Debug, Clone)]
pub(crate) struct ConnectorsSnapshot {
pub(crate) connectors: Vec<AppInfo>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@ -89,9 +95,21 @@ pub(crate) enum AppEvent {
/// Result of refreshing rate limits
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
ConnectorsLoaded(Result<ConnectorsSnapshot, String>),
/// Result of computing a `/diff` command.
DiffResult(String),
/// Open the app link view in the bottom pane.
OpenAppLink {
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
},
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,

View file

@ -0,0 +1,163 @@
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,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
complete: bool,
}
impl AppLinkView {
pub(crate) fn new(
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) -> Self {
Self {
title,
description,
instructions,
url,
is_installed,
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()));
if let Some(description) = self
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
for line in wrap(description, usable_width) {
lines.push(Line::from(line.into_owned().dim()));
}
}
lines.push(Line::from(""));
if self.is_installed {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
}
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()));
}
for line in wrap(
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
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(),
])
}

View file

@ -3,7 +3,7 @@
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
@ -124,6 +124,7 @@ use super::footer::single_line_footer_layout;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands;
use crate::bottom_pane::paste_burst::FlushResult;
@ -146,6 +147,7 @@ use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::textarea::TextArea;
@ -154,6 +156,8 @@ use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_chatgpt::connectors;
use codex_chatgpt::connectors::AppInfo;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@ -276,12 +280,15 @@ pub(crate) struct ChatComposer {
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
connectors_snapshot: Option<ConnectorsSnapshot>,
dismissed_mention_popup_token: Option<String>,
mention_paths: HashMap<String, String>,
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
personality_command_enabled: bool,
windows_degraded_sandbox_active: bool,
}
@ -363,11 +370,14 @@ impl ChatComposer {
context_window_percent: None,
context_window_used_tokens: None,
skills: None,
dismissed_skill_popup_token: None,
connectors_snapshot: None,
dismissed_mention_popup_token: None,
mention_paths: HashMap::new(),
steer_enabled: false,
collaboration_modes_enabled: false,
config,
collaboration_mode_indicator: None,
connectors_enabled: false,
personality_command_enabled: false,
windows_degraded_sandbox_active: false,
};
@ -380,6 +390,14 @@ impl ChatComposer {
self.skills = skills;
}
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
self.connectors_snapshot = connectors_snapshot;
}
pub(crate) fn take_mention_paths(&mut self) -> HashMap<String, String> {
std::mem::take(&mut self.mention_paths)
}
/// Enables or disables "Steer" behavior for submission keys.
///
/// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and
@ -394,6 +412,10 @@ impl ChatComposer {
self.collaboration_modes_enabled = enabled;
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.connectors_enabled = enabled;
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
@ -697,6 +719,7 @@ impl ChatComposer {
self.textarea.set_text_clearing_elements("");
self.pending_pastes.clear();
self.attached_images.clear();
self.mention_paths.clear();
self.textarea.set_text_with_elements(&text, &text_elements);
@ -1349,7 +1372,10 @@ impl ChatComposer {
unreachable!();
};
match key_event {
let mut selected_mention: Option<(String, Option<String>)> = None;
let mut close_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
@ -1376,8 +1402,8 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(tok) = self.current_skill_token() {
self.dismissed_skill_popup_token = Some(tok);
if let Some(tok) = self.current_mention_token() {
self.dismissed_mention_popup_token = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
@ -1390,15 +1416,26 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
let selected = popup.selected_skill().map(|skill| skill.name.clone());
if let Some(name) = selected {
self.insert_selected_skill(&name);
if let Some(mention) = popup.selected_mention() {
selected_mention = Some((mention.insert_text.clone(), mention.path.clone()));
}
self.active_popup = ActivePopup::None;
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some((insert_text, path)) = selected_mention {
if let Some(path) = path.as_deref() {
self.record_mention_path(&insert_text, path);
}
self.insert_selected_mention(&insert_text);
}
self.active_popup = ActivePopup::None;
}
result
}
fn is_image_path(path: &str) -> bool {
@ -1511,14 +1548,23 @@ 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()
}
fn mentions_enabled(&self) -> bool {
let skills_ready = self
.skills
.as_ref()
.is_some_and(|skills| !skills.is_empty());
let connectors_ready = self.connectors_enabled
&& self
.connectors_snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
skills_ready || connectors_ready
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
@ -1632,8 +1678,8 @@ impl ChatComposer {
Self::current_prefixed_token(textarea, '@', false)
}
fn current_skill_token(&self) -> Option<String> {
if !self.skills_enabled() {
fn current_mention_token(&self) -> Option<String> {
if !self.mentions_enabled() {
return None;
}
Self::current_prefixed_token(&self.textarea, '$', true)
@ -1691,7 +1737,7 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn insert_selected_skill(&mut self, skill_name: &str) {
fn insert_selected_mention(&mut self, insert_text: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
@ -1712,7 +1758,7 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
let inserted = format!("${skill_name}");
let inserted = insert_text.to_string();
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
@ -1721,12 +1767,35 @@ impl ChatComposer {
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
// Skill insertion rebuilds plain text, so drop existing elements.
// Mention insertion rebuilds plain text, so drop existing elements.
self.textarea.set_text_clearing_elements(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
fn record_mention_path(&mut self, insert_text: &str, path: &str) {
let Some(name) = Self::mention_name_from_insert_text(insert_text) else {
return;
};
self.mention_paths.insert(name, path.to_string());
}
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
let name = insert_text.strip_prefix('$')?;
if name.is_empty() {
return None;
}
if name
.as_bytes()
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some(name.to_string())
} else {
None
}
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
@ -1765,6 +1834,7 @@ impl ChatComposer {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@ -1951,6 +2021,7 @@ impl ChatComposer {
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@ -1979,6 +2050,7 @@ impl ChatComposer {
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@ -2388,10 +2460,10 @@ impl ChatComposer {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let mention_token = self.current_mention_token();
let allow_command_popup =
self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none();
self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
@ -2401,20 +2473,20 @@ impl ChatComposer {
self.current_file_query = None;
}
self.dismissed_file_popup_token = None;
self.dismissed_skill_popup_token = None;
self.dismissed_mention_popup_token = None;
return;
}
if let Some(token) = skill_token {
if let Some(token) = mention_token {
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.sync_skill_popup(token);
self.sync_mention_popup(token);
return;
}
self.dismissed_skill_popup_token = None;
self.dismissed_mention_popup_token = None;
if let Some(token) = file_token {
self.sync_file_search_popup(token);
@ -2477,6 +2549,7 @@ impl ChatComposer {
if slash_commands::has_builtin_prefix(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
) {
@ -2529,11 +2602,13 @@ impl ChatComposer {
_ => {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
@ -2594,32 +2669,99 @@ impl ChatComposer {
self.dismissed_file_popup_token = None;
}
fn sync_skill_popup(&mut self, query: String) {
if self.dismissed_skill_popup_token.as_ref() == Some(&query) {
fn sync_mention_popup(&mut self, query: String) {
if self.dismissed_mention_popup_token.as_ref() == Some(&query) {
return;
}
let skills = match self.skills.as_ref() {
Some(skills) if !skills.is_empty() => skills.clone(),
_ => {
self.active_popup = ActivePopup::None;
return;
}
};
let mentions = self.mention_items();
if mentions.is_empty() {
self.active_popup = ActivePopup::None;
return;
}
match &mut self.active_popup {
ActivePopup::Skill(popup) => {
popup.set_query(&query);
popup.set_skills(skills);
popup.set_mentions(mentions);
}
_ => {
let mut popup = SkillPopup::new(skills);
let mut popup = SkillPopup::new(mentions);
popup.set_query(&query);
self.active_popup = ActivePopup::Skill(popup);
}
}
}
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
let display_name = skill_display_name(skill).to_string();
let description = skill_description(skill);
let skill_name = skill.name.clone();
let search_terms = if display_name == skill.name {
vec![skill_name.clone()]
} else {
vec![skill_name.clone(), display_name.clone()]
};
mentions.push(MentionItem {
display_name,
description,
insert_text: format!("${skill_name}"),
search_terms,
path: Some(skill.path.to_string_lossy().into_owned()),
});
}
}
if self.connectors_enabled
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
{
for connector in &snapshot.connectors {
if !connector.is_accessible {
continue;
}
let display_name = connectors::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_core::connectors::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
});
}
}
mentions
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} - {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
@ -2658,6 +2800,29 @@ impl ChatComposer {
}
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
fn skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description);
let trimmed = description.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
@ -2938,6 +3103,7 @@ mod tests {
use tempfile::tempdir;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;

View file

@ -32,6 +32,7 @@ pub(crate) struct CommandPopup {
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@ -41,6 +42,7 @@ impl CommandPopup {
// Keep built-in availability in sync with the composer.
let builtins = slash_commands::builtins_for_input(
flags.collaboration_modes_enabled,
flags.connectors_enabled,
flags.personality_command_enabled,
flags.windows_degraded_sandbox_active,
);
@ -478,6 +480,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
windows_degraded_sandbox_active: false,
},
@ -496,6 +499,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: false,
windows_degraded_sandbox_active: false,
},
@ -522,6 +526,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
windows_degraded_sandbox_active: false,
},

View file

@ -13,8 +13,10 @@
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
@ -36,8 +38,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::time::Duration;
mod app_link_view;
mod approval_overlay;
mod request_user_input;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use request_user_input::RequestUserInputOverlay;
@ -204,6 +208,15 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_connectors_snapshot(&mut self, snapshot: Option<ConnectorsSnapshot>) {
self.composer.set_connector_mentions(snapshot);
self.request_redraw();
}
pub fn take_mention_paths(&mut self) -> HashMap<String, String> {
self.composer.take_mention_paths()
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.composer.set_steer_enabled(enabled);
}
@ -213,6 +226,10 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.composer.set_connectors_enabled(enabled);
}
#[cfg(target_os = "windows")]
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
self.composer.set_windows_degraded_sandbox_active(enabled);

View file

@ -14,30 +14,35 @@ use super::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt;
use codex_core::skills::model::SkillMetadata;
use crate::text_formatting::truncate_text;
use codex_common::fuzzy_match::fuzzy_match;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use crate::skills_helpers::truncated_skill_display_name;
#[derive(Clone, Debug)]
pub(crate) struct MentionItem {
pub(crate) display_name: String,
pub(crate) description: Option<String>,
pub(crate) insert_text: String,
pub(crate) search_terms: Vec<String>,
pub(crate) path: Option<String>,
}
pub(crate) struct SkillPopup {
query: String,
skills: Vec<SkillMetadata>,
mentions: Vec<MentionItem>,
state: ScrollState,
}
impl SkillPopup {
pub(crate) fn new(skills: Vec<SkillMetadata>) -> Self {
pub(crate) fn new(mentions: Vec<MentionItem>) -> Self {
Self {
query: String::new(),
skills,
mentions,
state: ScrollState::new(),
}
}
pub(crate) fn set_skills(&mut self, skills: Vec<SkillMetadata>) {
self.skills = skills;
pub(crate) fn set_mentions(&mut self, mentions: Vec<MentionItem>) {
self.mentions = mentions;
self.clamp_selection();
}
@ -64,11 +69,11 @@ impl SkillPopup {
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> {
pub(crate) fn selected_mention(&self) -> Option<&MentionItem> {
let matches = self.filtered_items();
let idx = self.state.selected_idx?;
let skill_idx = matches.get(idx)?;
self.skills.get(*skill_idx)
let mention_idx = matches.get(idx)?;
self.mentions.get(*mention_idx)
}
fn clamp_selection(&mut self) {
@ -88,14 +93,14 @@ impl SkillPopup {
matches
.into_iter()
.map(|(idx, indices, _score)| {
let skill = &self.skills[idx];
let name = truncated_skill_display_name(skill);
let description = skill_description(skill).to_string();
let mention = &self.mentions[idx];
let name = truncate_text(&mention.display_name, 21);
let description = mention.description.clone().unwrap_or_default();
GenericDisplayRow {
name,
match_indices: indices,
display_shortcut: None,
description: Some(description),
description: Some(description).filter(|desc| !desc.is_empty()),
is_disabled: false,
disabled_reason: None,
wrap_indent: None,
@ -109,23 +114,48 @@ impl SkillPopup {
let mut out: Vec<(usize, Option<Vec<usize>>, i32)> = Vec::new();
if filter.is_empty() {
for (idx, _skill) in self.skills.iter().enumerate() {
for (idx, _mention) in self.mentions.iter().enumerate() {
out.push((idx, None, 0));
}
return out;
}
for (idx, skill) in self.skills.iter().enumerate() {
let display_name = skill_display_name(skill);
if let Some((indices, score)) = match_skill(filter, display_name, &skill.name) {
for (idx, mention) in self.mentions.iter().enumerate() {
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));
}
for term in &mention.search_terms {
if term == &mention.display_name {
continue;
}
if let Some((_indices, score)) = fuzzy_match(term, filter) {
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((indices, score)) = best_match {
out.push((idx, indices, score));
}
}
out.sort_by(|a, b| {
a.2.cmp(&b.2).then_with(|| {
let an = skill_display_name(&self.skills[a.0]);
let bn = skill_display_name(&self.skills[b.0]);
let an = self.mentions[a.0].display_name.as_str();
let bn = self.mentions[b.0].display_name.as_str();
an.cmp(bn)
})
});
@ -154,7 +184,7 @@ impl WidgetRef for SkillPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
"no skills",
"no matches",
);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
@ -172,7 +202,7 @@ fn skill_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to select or ".into(),
" to insert or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])

View file

@ -11,6 +11,7 @@ use crate::slash_command::built_in_slash_commands;
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Vec<(&'static str, SlashCommand)> {
@ -18,6 +19,7 @@ pub(crate) fn builtins_for_input(
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.collect()
}
@ -26,11 +28,13 @@ pub(crate) fn builtins_for_input(
pub(crate) fn find_builtin_command(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Option<SlashCommand> {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
allow_elevate_sandbox,
)
@ -43,11 +47,13 @@ pub(crate) fn find_builtin_command(
pub(crate) fn has_builtin_prefix(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
allow_elevate_sandbox,
)

View file

@ -31,6 +31,7 @@ use std::time::Instant;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AuthMode;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_core::config::ConstraintResult;
use codex_core::config::types::Notifications;
@ -131,6 +132,7 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
@ -189,7 +191,9 @@ pub(crate) use self::agent::spawn_op_forwarder;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
use self::skills::find_skill_mentions;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
use crate::streaming::controller::StreamController;
use std::path::Path;
@ -386,6 +390,15 @@ enum RateLimitSwitchPromptState {
Shown,
}
#[derive(Debug, Clone, Default)]
enum ConnectorsCacheState {
#[default]
Uninitialized,
Loading,
Ready(ConnectorsSnapshot),
Failed(String),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ExternalEditorState {
#[default]
@ -460,6 +473,7 @@ pub(crate) struct ChatWidget {
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
/// currently executing.
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
connectors_cache: ConnectorsCacheState,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@ -549,6 +563,7 @@ pub(crate) struct UserMessage {
text: String,
local_images: Vec<LocalImageAttachment>,
text_elements: Vec<TextElement>,
mention_paths: HashMap<String, String>,
}
impl From<String> for UserMessage {
@ -558,6 +573,7 @@ impl From<String> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
}
}
}
@ -569,6 +585,7 @@ impl From<&str> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
}
}
}
@ -594,6 +611,7 @@ pub(crate) fn create_initial_user_message(
text,
local_images,
text_elements,
mention_paths: HashMap::new(),
})
}
}
@ -607,12 +625,14 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text,
text_elements,
local_images,
mention_paths,
} = message;
if local_images.is_empty() {
return UserMessage {
text,
text_elements,
local_images,
mention_paths,
};
}
@ -667,6 +687,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text: rebuilt,
local_images: remapped_images,
text_elements: rebuilt_elements,
mention_paths,
}
}
@ -732,6 +753,7 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.set_skills(None);
self.bottom_pane.set_connectors_snapshot(None);
self.thread_id = Some(event.session_id);
self.forked_from = event.forked_from_id;
self.current_rollout_path = event.rollout_path.clone();
@ -762,6 +784,9 @@ impl ChatWidget {
cwds: Vec::new(),
force_reload: true,
});
if self.connectors_enabled() {
self.prefetch_connectors();
}
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@ -797,6 +822,25 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn open_app_link_view(
&mut self,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) {
let view = crate::bottom_pane::AppLinkView::new(
title,
description,
instructions,
url,
is_installed,
);
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(),
@ -1240,6 +1284,7 @@ impl ChatWidget {
text: self.bottom_pane.composer_text(),
text_elements: self.bottom_pane.composer_text_elements(),
local_images: self.bottom_pane.composer_local_images(),
mention_paths: HashMap::new(),
};
let mut to_merge: Vec<UserMessage> = self.queued_user_messages.drain(..).collect();
@ -1251,6 +1296,7 @@ impl ChatWidget {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
mention_paths: HashMap::new(),
};
let mut combined_offset = 0usize;
let mut next_image_label = 1usize;
@ -1272,6 +1318,7 @@ impl ChatWidget {
elem
}));
combined.local_images.extend(message.local_images);
combined.mention_paths.extend(message.mention_paths);
}
Some(combined)
@ -2028,6 +2075,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@ -2071,6 +2119,10 @@ impl ChatWidget {
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
widget
}
@ -2161,6 +2213,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@ -2287,6 +2340,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@ -2436,6 +2490,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
if self.is_session_configured() {
// Submitted is only emitted when steer is enabled (Enter sends immediately).
@ -2458,6 +2513,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
self.queue_user_message(user_message);
}
@ -2675,6 +2731,9 @@ impl ChatWidget {
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
@ -2833,6 +2892,7 @@ impl ChatWidget {
text,
local_images,
text_elements,
mention_paths,
} = user_message;
if text.is_empty() && local_images.is_empty() {
return;
@ -2871,8 +2931,15 @@ impl ChatWidget {
});
}
let mentions = collect_tool_mentions(&text, &mention_paths);
let mut skill_names_lower: HashSet<String> = HashSet::new();
if let Some(skills) = self.bottom_pane.skills() {
let skill_mentions = find_skill_mentions(&text, skills);
skill_names_lower = skills
.iter()
.map(|skill| skill.name.to_ascii_lowercase())
.collect();
let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills);
for skill in skill_mentions {
items.push(UserInput::Skill {
name: skill.name.clone(),
@ -2881,6 +2948,17 @@ impl ChatWidget {
}
}
if let Some(apps) = self.connectors_for_mentions() {
let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower);
for app in app_mentions {
let app_id = app.id.as_str();
items.push(UserInput::Mention {
name: app.name.clone(),
path: format!("app://{app_id}"),
});
}
}
let effective_mode = self.effective_collaboration_mode();
let collaboration_mode = if self.collaboration_modes_enabled() {
self.active_collaboration_mask
@ -3289,6 +3367,28 @@ impl ChatWidget {
}
}
fn prefetch_connectors(&mut self) {
if !self.connectors_enabled() {
return;
}
if matches!(self.connectors_cache, ConnectorsCacheState::Loading) {
return;
}
self.connectors_cache = ConnectorsCacheState::Loading;
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result: Result<ConnectorsSnapshot, anyhow::Error> = async {
let connectors = connectors::list_connectors(&config).await?;
Ok(ConnectorsSnapshot { connectors })
}
.await;
let result = result.map_err(|err| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded(result));
});
}
fn prefetch_rate_limits(&mut self) {
self.stop_rate_limit_poller();
@ -4973,6 +5073,21 @@ impl ChatWidget {
self.request_redraw();
}
fn connectors_enabled(&self) -> bool {
self.config.features.enabled(Feature::Apps)
}
fn connectors_for_mentions(&self) -> Option<&[connectors::AppInfo]> {
if !self.connectors_enabled() {
return None;
}
match &self.connectors_cache {
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
_ => None,
}
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
@ -5036,6 +5151,152 @@ impl ChatWidget {
}
}
pub(crate) fn add_connectors_output(&mut self) {
if !self.connectors_enabled() {
self.add_info_message(
"Apps are disabled.".to_string(),
Some("Enable the apps feature to use $ or /apps.".to_string()),
);
return;
}
match self.connectors_cache.clone() {
ConnectorsCacheState::Ready(snapshot) => {
if snapshot.connectors.is_empty() {
self.add_info_message("No apps available.".to_string(), None);
} else {
self.open_connectors_popup(&snapshot.connectors);
}
}
ConnectorsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
// Retry on demand so `/apps` can recover after transient failures.
self.prefetch_connectors();
}
ConnectorsCacheState::Loading => {
self.add_to_history(history_cell::new_info_event(
"Apps are still loading.".to_string(),
Some("Try again in a moment.".to_string()),
));
}
ConnectorsCacheState::Uninitialized => {
self.prefetch_connectors();
self.add_to_history(history_cell::new_info_event(
"Apps are still loading.".to_string(),
Some("Try again in a moment.".to_string()),
));
}
}
self.request_redraw();
}
fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) {
let total = connectors.len();
let installed = connectors
.iter()
.filter(|connector| connector.is_accessible)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from(
"Use $ to insert an installed app into your prompt.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available apps.").dim(),
));
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 link_description = Self::connector_description(connector);
let description = Self::connector_brief_description(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
description: Some(description),
search_value: Some(search_value),
..Default::default()
};
let is_installed = connector.is_accessible;
let (selected_label, missing_label, instructions) = if connector.is_accessible {
(
"Press Enter to view the app link.",
"App link unavailable.",
"Manage this app 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();
let description = link_description.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
title: title.clone(),
description: description.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
is_installed,
});
})];
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);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(Self::connectors_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
..Default::default()
});
}
fn connectors_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close.".into(),
])
}
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
/// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
@ -5220,6 +5481,19 @@ impl ChatWidget {
self.set_skills_from_response(&ev);
}
pub(crate) fn on_connectors_loaded(&mut self, result: Result<ConnectorsSnapshot, String>) {
self.connectors_cache = match result {
Ok(connectors) => ConnectorsCacheState::Ready(connectors),
Err(err) => ConnectorsCacheState::Failed(err),
};
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache {
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else {
self.bottom_pane.set_connectors_snapshot(None);
}
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();

View file

@ -12,6 +12,8 @@ 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_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_core::protocol::SkillsListEntry;
@ -192,22 +194,256 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
}
}
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())
}
pub(crate) fn collect_tool_mentions(
text: &str,
mention_paths: &HashMap<String, String>,
) -> ToolMentions {
let mut mentions = extract_tool_mentions_from_text(text);
for (name, path) in mention_paths {
if mentions.names.contains(name) {
mentions.linked_paths.insert(name.clone(), path.clone());
}
}
mentions
}
pub(crate) fn find_skill_mentions_with_tool_mentions(
mentions: &ToolMentions,
skills: &[SkillMetadata],
) -> Vec<SkillMetadata> {
let mention_skill_paths: HashSet<&str> = mentions
.linked_paths
.values()
.filter(|path| is_skill_path(path))
.map(|path| normalize_skill_path(path))
.collect();
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
let path_str = skill.path.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
matches.push(skill.clone());
}
}
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path.clone());
matches.push(skill.clone());
}
}
matches
}
pub(crate) fn find_app_mentions(
mentions: &ToolMentions,
apps: &[AppInfo],
skill_names_lower: &HashSet<String>,
) -> Vec<AppInfo> {
let mut explicit_names = HashSet::new();
let mut selected_ids = HashSet::new();
for (name, path) in &mentions.linked_paths {
if let Some(connector_id) = app_id_from_path(path) {
explicit_names.insert(name.clone());
selected_ids.insert(connector_id.to_string());
}
}
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for app in apps {
let slug = connector_mention_slug(app);
*slug_counts.entry(slug).or_insert(0) += 1;
}
for app in apps {
let slug = connector_mention_slug(app);
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
if mentions.names.contains(&slug)
&& !explicit_names.contains(&slug)
&& slug_count == 1
&& !skill_names_lower.contains(&slug)
{
selected_ids.insert(app.id.clone());
}
}
apps.iter()
.filter(|app| selected_ids.contains(&app.id))
.cloned()
.collect()
}
pub(crate) struct ToolMentions {
names: HashSet<String>,
linked_paths: HashMap<String, String>,
}
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
let text_bytes = text.as_bytes();
let mut names: HashSet<String> = HashSet::new();
let mut linked_paths: HashMap<String, String> = HashMap::new();
let mut index = 0;
while index < text_bytes.len() {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, index)
{
if !is_common_env_var(name) {
if !is_app_or_mcp_path(path) {
names.insert(name.to_string());
}
linked_paths
.entry(name.to_string())
.or_insert(path.to_string());
}
index = end_index;
continue;
}
if byte != b'$' {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first_name_byte) = text_bytes.get(name_start) else {
index += 1;
continue;
};
if !is_mention_name_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
names.insert(name.to_string());
}
index = name_end;
}
ToolMentions {
names,
linked_paths,
}
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
) -> Option<(&'a str, &'a str, usize)> {
let dollar_index = start + 1;
if text_bytes.get(dollar_index) != Some(&b'$') {
return None;
}
let name_start = dollar_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_mention_name_char(*first_name_byte) {
return None;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
if text_bytes.get(name_end) != Some(&b']') {
return None;
}
let mut path_start = name_end + 1;
while let Some(next_byte) = text_bytes.get(path_start)
&& next_byte.is_ascii_whitespace()
{
path_start += 1;
}
if text_bytes.get(path_start) != Some(&b'(') {
return None;
}
let mut path_end = path_start + 1;
while let Some(next_byte) = text_bytes.get(path_end)
&& *next_byte != b')'
{
path_end += 1;
}
if text_bytes.get(path_end) != Some(&b')') {
return None;
}
let path = text[path_start + 1..path_end].trim();
if path.is_empty() {
return None;
}
let name = &text[name_start..name_end];
Some((name, path, path_end + 1))
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"PATH"
| "HOME"
| "USER"
| "SHELL"
| "PWD"
| "TMPDIR"
| "TEMP"
| "TMP"
| "LANG"
| "TERM"
| "XDG_CONFIG_HOME"
)
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn is_skill_path(path: &str) -> bool {
!is_app_or_mcp_path(path)
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix("skill://").unwrap_or(path)
}
fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix("app://")
.filter(|value| !value.is_empty())
}
fn is_app_or_mcp_path(path: &str) -> bool {
path.starts_with("app://") || path.starts_with("mcp://")
}

View file

@ -355,6 +355,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
path: first_images[0].clone(),
}],
text_elements: first_elements,
mention_paths: HashMap::new(),
});
chat.queued_user_messages.push_back(UserMessage {
text: second_text,
@ -363,6 +364,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
path: second_images[0].clone(),
}],
text_elements: second_elements,
mention_paths: HashMap::new(),
});
chat.refresh_queued_user_messages();
@ -442,6 +444,7 @@ async fn remap_placeholders_uses_attachment_labels() {
text,
text_elements: elements,
local_images: attachments,
mention_paths: HashMap::new(),
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);
@ -502,6 +505,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
text,
text_elements: elements,
local_images: attachments,
mention_paths: HashMap::new(),
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);
@ -810,6 +814,7 @@ async fn make_chatwidget_manual(
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),

View file

@ -26,10 +26,6 @@ pub(crate) fn truncate_skill_name(name: &str) -> String {
truncate_text(name, SKILL_NAME_TRUNCATE_LEN)
}
pub(crate) fn truncated_skill_display_name(skill: &SkillMetadata) -> String {
truncate_skill_name(skill_display_name(skill))
}
pub(crate) fn match_skill(
filter: &str,
display_name: &str,

View file

@ -33,6 +33,7 @@ pub enum SlashCommand {
Mention,
Status,
Mcp,
Apps,
Logout,
Quit,
Exit,
@ -69,6 +70,7 @@ impl SlashCommand {
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Apps => "manage apps",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
SlashCommand::TestApproval => "test approval request",
@ -104,6 +106,7 @@ impl SlashCommand {
| SlashCommand::Status
| SlashCommand::Ps
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit => true,