[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:
parent
ecc66f4f52
commit
b9cd089d1f
36 changed files with 2028 additions and 365 deletions
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
64
codex-rs/core/src/mentions.rs
Normal file
64
codex-rs/core/src/mentions.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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".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".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".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".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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>>(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
163
codex-rs/tui/src/bottom_pane/app_link_view.rs
Normal file
163
codex-rs/tui/src/bottom_pane/app_link_view.rs
Normal 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(),
|
||||
])
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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://")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue