mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
update
This commit is contained in:
Generated
+5
@@ -1005,6 +1005,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-feedback",
|
||||
@@ -1750,6 +1751,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
@@ -1795,6 +1797,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml 0.9.5",
|
||||
"toml_edit 0.24.0+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -1825,6 +1828,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
@@ -1871,6 +1875,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml 0.9.5",
|
||||
"toml_edit 0.24.0+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -133,6 +133,10 @@ client_request_definitions! {
|
||||
params: v2::SkillsListParams,
|
||||
response: v2::SkillsListResponse,
|
||||
},
|
||||
ConnectorsList => "connectors/list" {
|
||||
params: v2::ConnectorsListParams,
|
||||
response: v2::ConnectorsListResponse,
|
||||
},
|
||||
SkillsConfigWrite => "skills/config/write" {
|
||||
params: v2::SkillsConfigWriteParams,
|
||||
response: v2::SkillsConfigWriteResponse,
|
||||
|
||||
@@ -962,6 +962,43 @@ pub struct ListMcpServerStatusResponse {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConnectorsListParams {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConnectorInfo {
|
||||
#[serde(rename = "id")]
|
||||
pub connector_id: String,
|
||||
#[serde(rename = "name")]
|
||||
pub connector_name: String,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
rename = "description"
|
||||
)]
|
||||
#[ts(optional)]
|
||||
pub connector_description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub logo_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub install_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_accessible: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConnectorsListResponse {
|
||||
pub data: Vec<ConnectorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -22,6 +22,7 @@ codex-common = { workspace = true, features = ["cli"] }
|
||||
codex-core = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
|
||||
@@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination).
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `connectors/list` — list available connectors.
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
|
||||
@@ -26,6 +26,9 @@ use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::ConnectorInfo as ApiConnectorInfo;
|
||||
use codex_app_server_protocol::ConnectorsListParams;
|
||||
use codex_app_server_protocol::ConnectorsListResponse;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::ExecOneOffCommandResponse;
|
||||
@@ -120,6 +123,7 @@ use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_app_server_protocol::build_turns_from_event_msgs;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::Cursor as RolloutCursor;
|
||||
@@ -405,6 +409,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::SkillsList { request_id, params } => {
|
||||
self.skills_list(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ConnectorsList { request_id, params } => {
|
||||
self.connectors_list(request_id, params).await;
|
||||
}
|
||||
ClientRequest::SkillsConfigWrite { request_id, params } => {
|
||||
self.skills_config_write(request_id, params).await;
|
||||
}
|
||||
@@ -3263,6 +3270,49 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn connectors_list(&self, request_id: RequestId, params: ConnectorsListParams) {
|
||||
let ConnectorsListParams {} = params;
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !config.features.enabled(Feature::Connectors) {
|
||||
self.outgoing
|
||||
.send_response(request_id, ConnectorsListResponse { data: Vec::new() })
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let connectors = match connectors::list_connectors(&config).await {
|
||||
Ok(connectors) => connectors,
|
||||
Err(err) => {
|
||||
self.send_internal_error(request_id, format!("failed to list connectors: {err}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let data = connectors
|
||||
.into_iter()
|
||||
.map(|connector| ApiConnectorInfo {
|
||||
connector_id: connector.connector_id,
|
||||
connector_name: connector.connector_name,
|
||||
connector_description: connector.connector_description,
|
||||
logo_url: connector.logo_url,
|
||||
install_url: connector.install_url,
|
||||
is_accessible: connector.is_accessible,
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, ConnectorsListResponse { data })
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) {
|
||||
let SkillsListParams { cwds, force_reload } = params;
|
||||
let cwds = if cwds.is_empty() {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
/// Make a GET request to the ChatGPT backend API.
|
||||
@@ -48,3 +49,37 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
|
||||
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")?;
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use codex_core::config::Config;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::chatgpt_client::chatgpt_post_request;
|
||||
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::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>,
|
||||
}
|
||||
|
||||
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
|
||||
let (connectors_result, accessible_result) = tokio::join!(
|
||||
list_all_connectors(config),
|
||||
list_accessible_connectors_from_mcp_tools(config),
|
||||
);
|
||||
let connectors = connectors_result?;
|
||||
let accessible = accessible_result?;
|
||||
Ok(merge_connectors(connectors, accessible))
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
|
||||
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
|
||||
.await?;
|
||||
|
||||
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;
|
||||
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),
|
||||
};
|
||||
connector.connector_name =
|
||||
normalize_connector_name(&connector.connector_name, &connector.connector_id);
|
||||
connector.connector_description =
|
||||
normalize_connector_value(connector.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))
|
||||
});
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
connector_id.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod apply_command;
|
||||
mod chatgpt_client;
|
||||
mod chatgpt_token;
|
||||
pub mod connectors;
|
||||
pub mod get_task;
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"collaboration_modes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connectors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"elevated_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -359,12 +362,12 @@
|
||||
},
|
||||
"shell_environment_policy": {
|
||||
"default": {
|
||||
"inherit": null,
|
||||
"ignore_default_excludes": null,
|
||||
"exclude": null,
|
||||
"set": null,
|
||||
"experimental_use_profile": null,
|
||||
"ignore_default_excludes": null,
|
||||
"include_only": null,
|
||||
"experimental_use_profile": null
|
||||
"inherit": null,
|
||||
"set": null
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
@@ -563,6 +566,9 @@
|
||||
"collaboration_modes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connectors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"elevated_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -996,6 +996,7 @@ mod tests {
|
||||
id_token: IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
chatgpt_account_id: None,
|
||||
raw_jwt: fake_jwt,
|
||||
},
|
||||
|
||||
+104
-15
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -17,6 +18,7 @@ use crate::compact;
|
||||
use crate::compact::run_inline_auto_compact_task;
|
||||
use crate::compact::should_use_remote_compact_task;
|
||||
use crate::compact_remote::run_inline_remote_auto_compact_task;
|
||||
use crate::connectors;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::features::Feature;
|
||||
use crate::features::Features;
|
||||
@@ -101,7 +103,10 @@ use crate::exec::StreamOutput;
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::effective_mcp_servers;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
@@ -617,14 +622,25 @@ impl Session {
|
||||
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
|
||||
|
||||
let history_meta_fut = crate::message_history::history_metadata(&config);
|
||||
let auth_statuses_fut = compute_auth_statuses(
|
||||
config.mcp_servers.iter(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
);
|
||||
let auth_manager_clone = Arc::clone(&auth_manager);
|
||||
let config_for_mcp = Arc::clone(&config);
|
||||
let auth_and_mcp_fut = async move {
|
||||
let auth = auth_manager_clone.auth().await;
|
||||
let mcp_servers = effective_mcp_servers(&config_for_mcp, auth.as_ref());
|
||||
let auth_statuses = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
config_for_mcp.mcp_oauth_credentials_store_mode,
|
||||
)
|
||||
.await;
|
||||
(auth, mcp_servers, auth_statuses)
|
||||
};
|
||||
|
||||
// Join all independent futures.
|
||||
let (rollout_recorder, (history_log_id, history_entry_count), auth_statuses) =
|
||||
tokio::join!(rollout_fut, history_meta_fut, auth_statuses_fut);
|
||||
let (
|
||||
rollout_recorder,
|
||||
(history_log_id, history_entry_count),
|
||||
(auth, mcp_servers, auth_statuses),
|
||||
) = tokio::join!(rollout_fut, history_meta_fut, auth_and_mcp_fut);
|
||||
|
||||
let rollout_recorder = rollout_recorder.map_err(|e| {
|
||||
error!("failed to initialize rollout recorder: {e:#}");
|
||||
@@ -651,7 +667,6 @@ impl Session {
|
||||
}
|
||||
maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events);
|
||||
|
||||
let auth = auth_manager.auth().await;
|
||||
let auth = auth.as_ref();
|
||||
let otel_manager = OtelManager::new(
|
||||
conversation_id,
|
||||
@@ -686,7 +701,7 @@ impl Session {
|
||||
config.model_auto_compact_token_limit,
|
||||
config.approval_policy.value(),
|
||||
config.sandbox_policy.get().clone(),
|
||||
config.mcp_servers.keys().map(String::as_str).collect(),
|
||||
mcp_servers.keys().map(String::as_str).collect(),
|
||||
config.active_profile.clone(),
|
||||
);
|
||||
|
||||
@@ -768,7 +783,7 @@ impl Session {
|
||||
.write()
|
||||
.await
|
||||
.initialize(
|
||||
&config.mcp_servers,
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
tx_event.clone(),
|
||||
@@ -1891,6 +1906,12 @@ impl Session {
|
||||
}
|
||||
};
|
||||
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let mcp_servers = with_codex_apps_mcp(
|
||||
mcp_servers,
|
||||
self.features.enabled(Feature::Connectors),
|
||||
auth.as_ref(),
|
||||
);
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
@@ -2070,6 +2091,7 @@ mod handlers {
|
||||
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::mcp::effective_mcp_servers;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::tasks::CompactTask;
|
||||
use crate::tasks::RegularTask;
|
||||
@@ -2381,13 +2403,12 @@ mod handlers {
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let mcp_servers = effective_mcp_servers(config, auth.as_ref());
|
||||
let snapshot = collect_mcp_snapshot_from_manager(
|
||||
&mcp_connection_manager,
|
||||
compute_auth_statuses(
|
||||
config.mcp_servers.iter(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
)
|
||||
.await,
|
||||
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let event = Event {
|
||||
@@ -2896,6 +2917,60 @@ async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>)
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_connectors_for_input(
|
||||
connectors: Vec<connectors::ConnectorInfo>,
|
||||
input: &[ResponseItem],
|
||||
) -> Vec<connectors::ConnectorInfo> {
|
||||
let user_messages = collect_user_messages(input);
|
||||
if user_messages.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
connectors
|
||||
.into_iter()
|
||||
.filter(|connector| connector_inserted_in_messages(connector, &user_messages))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn connector_inserted_in_messages(
|
||||
connector: &connectors::ConnectorInfo,
|
||||
user_messages: &[String],
|
||||
) -> 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)
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
connectors: &[connectors::ConnectorInfo],
|
||||
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
|
||||
let allowed: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.map(|connector| connector.connector_id.as_str())
|
||||
.collect();
|
||||
|
||||
mcp_tools.retain(|_, tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return true;
|
||||
}
|
||||
let Some(connector_id) = codex_apps_connector_id(tool) else {
|
||||
return false;
|
||||
};
|
||||
allowed.contains(connector_id)
|
||||
});
|
||||
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> {
|
||||
tool.connector_id.as_deref()
|
||||
}
|
||||
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
@@ -2912,7 +2987,7 @@ async fn run_sampling_request(
|
||||
input: Vec<ResponseItem>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<SamplingRequestResult> {
|
||||
let mcp_tools = sess
|
||||
let mut mcp_tools = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
@@ -2920,6 +2995,20 @@ 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 = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
|
||||
Some(filter_connectors_for_input(connectors, &input))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(connectors) = connectors_for_tools.as_ref() {
|
||||
mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, connectors);
|
||||
}
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
Some(
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
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::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) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref());
|
||||
if mcp_servers.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let auth_status_entries =
|
||||
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
|
||||
|
||||
let mut mcp_connection_manager = McpConnectionManager::default();
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
};
|
||||
|
||||
mcp_connection_manager
|
||||
.initialize(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries,
|
||||
tx_event,
|
||||
cancel_token.clone(),
|
||||
sandbox_state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let tools = mcp_connection_manager.list_all_tools().await;
|
||||
cancel_token.cancel();
|
||||
|
||||
Ok(accessible_connectors_from_mcp_tools(&tools))
|
||||
}
|
||||
|
||||
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
|
||||
AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connector_display_label(connector: &ConnectorInfo) -> String {
|
||||
format_connector_label(&connector.connector_name, &connector.connector_id)
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) -> Vec<ConnectorInfo> {
|
||||
let tools = mcp_tools.values().filter_map(|tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
}
|
||||
let connector_id = tool.connector_id.as_deref()?;
|
||||
let connector_name = normalize_connector_value(tool.connector_name.as_deref());
|
||||
Some((connector_id.to_string(), connector_name))
|
||||
});
|
||||
collect_accessible_connectors(tools)
|
||||
}
|
||||
|
||||
pub fn merge_connectors(
|
||||
connectors: Vec<ConnectorInfo>,
|
||||
accessible_connectors: Vec<ConnectorInfo>,
|
||||
) -> Vec<ConnectorInfo> {
|
||||
let mut merged: HashMap<String, ConnectorInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|mut connector| {
|
||||
connector.is_accessible = false;
|
||||
(connector.connector_id.clone(), connector)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for mut connector in accessible_connectors {
|
||||
connector.is_accessible = true;
|
||||
let connector_id = connector.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.connector_description.is_none() && connector.connector_description.is_some()
|
||||
{
|
||||
existing.connector_description = connector.connector_description;
|
||||
}
|
||||
if existing.logo_url.is_none() && connector.logo_url.is_some() {
|
||||
existing.logo_url = connector.logo_url;
|
||||
}
|
||||
} else {
|
||||
merged.insert(connector_id, connector);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
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))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<ConnectorInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
{
|
||||
let mut connectors: HashMap<String, String> = HashMap::new();
|
||||
for (connector_id, connector_name) in tools {
|
||||
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
|
||||
if let Some(existing_name) = connectors.get_mut(&connector_id) {
|
||||
if existing_name == &connector_id && connector_name != connector_id {
|
||||
*existing_name = connector_name;
|
||||
}
|
||||
} else {
|
||||
connectors.insert(connector_id, connector_name);
|
||||
}
|
||||
}
|
||||
let mut accessible: Vec<ConnectorInfo> = 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,
|
||||
logo_url: None,
|
||||
is_accessible: true,
|
||||
})
|
||||
.collect();
|
||||
accessible.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))
|
||||
});
|
||||
accessible
|
||||
}
|
||||
|
||||
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
|
||||
let slug = connector_name_slug(name);
|
||||
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
|
||||
}
|
||||
|
||||
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() {
|
||||
normalized.push(character.to_ascii_lowercase());
|
||||
} else {
|
||||
normalized.push('-');
|
||||
}
|
||||
}
|
||||
let normalized = normalized.trim_matches('-');
|
||||
if normalized.is_empty() {
|
||||
"app".to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_connector_label(name: &str, _id: &str) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
@@ -103,6 +103,8 @@ pub enum Feature {
|
||||
EnableRequestCompression,
|
||||
/// Enable collab tools.
|
||||
Collab,
|
||||
/// Enable connectors (apps).
|
||||
Connectors,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
/// Enable collaboration modes (Plan, Pair Programming, Execute).
|
||||
@@ -435,6 +437,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Beta,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Connectors,
|
||||
key: "connectors",
|
||||
stage: Stage::Beta,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Tui2,
|
||||
key: "tui2",
|
||||
|
||||
@@ -20,6 +20,7 @@ mod codex_delegate;
|
||||
mod command_safety;
|
||||
pub mod config;
|
||||
pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
pub mod custom_prompts;
|
||||
pub mod env;
|
||||
|
||||
@@ -9,16 +9,116 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
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";
|
||||
const CODEX_APPS_MCP_URL: &str = "http://localhost:10000/backend-api/wham/apps";
|
||||
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
|
||||
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
||||
match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
|
||||
Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
||||
Ok(_) => None,
|
||||
Err(env::VarError::NotPresent) => None,
|
||||
Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option<String> {
|
||||
let token = auth.and_then(|auth| auth.get_token().ok())?;
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(token.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option<HashMap<String, String>> {
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(token) = codex_apps_mcp_bearer_token(auth) {
|
||||
headers.insert("Authorization".to_string(), format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) {
|
||||
headers.insert("ChatGPT-Account-ID".to_string(), account_id);
|
||||
}
|
||||
if headers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(headers)
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_server_config(auth: Option<&CodexAuth>) -> McpServerConfig {
|
||||
let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var();
|
||||
let http_headers = if bearer_token_env_var.is_some() {
|
||||
None
|
||||
} else {
|
||||
codex_apps_mcp_http_headers(auth)
|
||||
};
|
||||
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: CODEX_APPS_MCP_URL.to_string(),
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_codex_apps_mcp(
|
||||
mut servers: HashMap<String, McpServerConfig>,
|
||||
connectors_enabled: bool,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
if connectors_enabled {
|
||||
servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_apps_mcp_server_config(auth),
|
||||
);
|
||||
} else {
|
||||
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
pub(crate) fn effective_mcp_servers(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
with_codex_apps_mcp(
|
||||
config.mcp_servers.get().clone(),
|
||||
config.features.enabled(Feature::Connectors),
|
||||
auth,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent {
|
||||
if config.mcp_servers.is_empty() {
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_servers = effective_mcp_servers(config, auth.as_ref());
|
||||
if mcp_servers.is_empty() {
|
||||
return McpListToolsResponseEvent {
|
||||
tools: HashMap::new(),
|
||||
resources: HashMap::new(),
|
||||
@@ -27,11 +127,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
};
|
||||
}
|
||||
|
||||
let auth_status_entries = compute_auth_statuses(
|
||||
config.mcp_servers.iter(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
)
|
||||
.await;
|
||||
let auth_status_entries =
|
||||
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
|
||||
|
||||
let mut mcp_connection_manager = McpConnectionManager::default();
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
@@ -47,7 +144,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
|
||||
mcp_connection_manager
|
||||
.initialize(
|
||||
&config.mcp_servers,
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries.clone(),
|
||||
tx_event,
|
||||
|
||||
@@ -153,6 +153,8 @@ pub(crate) struct ToolInfo {
|
||||
pub(crate) server_name: String,
|
||||
pub(crate) tool_name: String,
|
||||
pub(crate) tool: Tool,
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) connector_name: Option<String>,
|
||||
}
|
||||
|
||||
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
|
||||
@@ -899,14 +901,16 @@ async fn list_tools_for_client(
|
||||
client: &Arc<RmcpClient>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Vec<ToolInfo>> {
|
||||
let resp = client.list_tools(None, timeout).await?;
|
||||
let resp = client.list_tools_with_connector_ids(None, timeout).await?;
|
||||
Ok(resp
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| ToolInfo {
|
||||
server_name: server_name.to_owned(),
|
||||
tool_name: tool.name.clone(),
|
||||
tool,
|
||||
tool_name: tool.tool.name.clone(),
|
||||
tool: tool.tool,
|
||||
connector_id: tool.connector_id,
|
||||
connector_name: tool.connector_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1004,6 +1008,8 @@ mod tests {
|
||||
output_schema: None,
|
||||
title: None,
|
||||
},
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct IdTokenInfo {
|
||||
/// (e.g., "free", "plus", "pro", "business", "enterprise", "edu").
|
||||
/// (Note: values may vary by backend.)
|
||||
pub(crate) chatgpt_plan_type: Option<PlanType>,
|
||||
/// ChatGPT user identifier associated with the token, if present.
|
||||
pub chatgpt_user_id: Option<String>,
|
||||
/// Organization/workspace identifier associated with the token, if present.
|
||||
pub chatgpt_account_id: Option<String>,
|
||||
pub raw_jwt: String,
|
||||
@@ -74,6 +76,10 @@ struct AuthClaims {
|
||||
#[serde(default)]
|
||||
chatgpt_plan_type: Option<PlanType>,
|
||||
#[serde(default)]
|
||||
chatgpt_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -103,12 +109,14 @@ pub fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||
email: claims.email,
|
||||
raw_jwt: id_token.to_string(),
|
||||
chatgpt_plan_type: auth.chatgpt_plan_type,
|
||||
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
|
||||
chatgpt_account_id: auth.chatgpt_account_id,
|
||||
}),
|
||||
None => Ok(IdTokenInfo {
|
||||
email: claims.email,
|
||||
raw_jwt: id_token.to_string(),
|
||||
chatgpt_plan_type: None,
|
||||
chatgpt_user_id: None,
|
||||
chatgpt_account_id: None,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -22,5 +22,7 @@ pub use perform_oauth_login::perform_oauth_login_return_url;
|
||||
pub use rmcp::model::ElicitationAction;
|
||||
pub use rmcp_client::Elicitation;
|
||||
pub use rmcp_client::ElicitationResponse;
|
||||
pub use rmcp_client::ListToolsWithConnectorIdResult;
|
||||
pub use rmcp_client::RmcpClient;
|
||||
pub use rmcp_client::SendElicitation;
|
||||
pub use rmcp_client::ToolWithConnectorId;
|
||||
|
||||
@@ -23,6 +23,7 @@ use mcp_types::ListToolsResult;
|
||||
use mcp_types::ReadResourceRequestParams;
|
||||
use mcp_types::ReadResourceResult;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::Tool;
|
||||
use reqwest::header::HeaderMap;
|
||||
use rmcp::model::CallToolRequestParam;
|
||||
use rmcp::model::ClientNotification;
|
||||
@@ -44,6 +45,7 @@ use rmcp::transport::auth::AuthClient;
|
||||
use rmcp::transport::auth::OAuthState;
|
||||
use rmcp::transport::child_process::TokioChildProcess;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
use serde_json::Value;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
@@ -95,6 +97,17 @@ pub type SendElicitation = Box<
|
||||
dyn Fn(RequestId, Elicitation) -> BoxFuture<'static, Result<ElicitationResponse>> + Send + Sync,
|
||||
>;
|
||||
|
||||
pub struct ToolWithConnectorId {
|
||||
pub tool: Tool,
|
||||
pub connector_id: Option<String>,
|
||||
pub connector_name: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ListToolsWithConnectorIdResult {
|
||||
pub next_cursor: Option<String>,
|
||||
pub tools: Vec<ToolWithConnectorId>,
|
||||
}
|
||||
|
||||
/// MCP client implemented on top of the official `rmcp` SDK.
|
||||
/// https://github.com/modelcontextprotocol/rust-sdk
|
||||
pub struct RmcpClient {
|
||||
@@ -286,6 +299,18 @@ impl RmcpClient {
|
||||
params: Option<ListToolsRequestParams>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ListToolsResult> {
|
||||
let result = self.list_tools_with_connector_ids(params, timeout).await?;
|
||||
Ok(ListToolsResult {
|
||||
next_cursor: result.next_cursor,
|
||||
tools: result.tools.into_iter().map(|tool| tool.tool).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_tools_with_connector_ids(
|
||||
&self,
|
||||
params: Option<ListToolsRequestParams>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ListToolsWithConnectorIdResult> {
|
||||
self.refresh_oauth_if_needed().await;
|
||||
let service = self.service().await?;
|
||||
let rmcp_params = params
|
||||
@@ -294,9 +319,35 @@ impl RmcpClient {
|
||||
|
||||
let fut = service.list_tools(rmcp_params);
|
||||
let result = run_with_timeout(fut, timeout, "tools/list").await?;
|
||||
let converted = convert_to_mcp(result)?;
|
||||
let tools = result
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let meta = tool.meta.as_ref();
|
||||
let connector_id = Self::meta_string(meta, "connector_id");
|
||||
let connector_name = Self::meta_string(meta, "connector_name")
|
||||
.or_else(|| Self::meta_string(meta, "connector_display_name"));
|
||||
let tool = convert_to_mcp(tool)?;
|
||||
Ok(ToolWithConnectorId {
|
||||
tool,
|
||||
connector_id,
|
||||
connector_name,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(converted)
|
||||
Ok(ListToolsWithConnectorIdResult {
|
||||
next_cursor: result.next_cursor,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
fn meta_string(meta: Option<&rmcp::model::Meta>, key: &str) -> Option<String> {
|
||||
meta.and_then(|meta| meta.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub async fn list_resources(
|
||||
|
||||
@@ -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",
|
||||
@@ -83,6 +84,7 @@ tokio = { workspace = true, features = [
|
||||
] }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
toml = { workspace = true }
|
||||
toml_edit = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -976,6 +976,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::ConnectorInfo;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
@@ -38,6 +39,11 @@ pub(crate) enum WindowsSandboxFallbackReason {
|
||||
ElevationFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ConnectorsSnapshot {
|
||||
pub(crate) connectors: Vec<ConnectorInfo>,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
@@ -87,6 +93,9 @@ 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),
|
||||
|
||||
|
||||
@@ -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/connector 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).
|
||||
@@ -92,6 +92,7 @@ use super::footer::reset_mode_after_activity;
|
||||
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 crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::bottom_pane::prompt_args::expand_custom_prompt;
|
||||
@@ -112,6 +113,7 @@ use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
@@ -119,6 +121,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::ConnectorInfo;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
@@ -197,10 +201,12 @@ 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>,
|
||||
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
|
||||
steer_enabled: bool,
|
||||
collaboration_modes_enabled: bool,
|
||||
connectors_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -258,9 +264,11 @@ 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,
|
||||
steer_enabled: false,
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -271,6 +279,10 @@ impl ChatComposer {
|
||||
self.skills = skills;
|
||||
}
|
||||
|
||||
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
|
||||
self.connectors_snapshot = connectors_snapshot;
|
||||
}
|
||||
|
||||
/// Enables or disables "Steer" behavior for submission keys.
|
||||
///
|
||||
/// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and
|
||||
@@ -285,6 +297,10 @@ impl ChatComposer {
|
||||
self.collaboration_modes_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||||
self.connectors_enabled = enabled;
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
@@ -1097,8 +1113,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)
|
||||
@@ -1111,9 +1127,11 @@ 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);
|
||||
let selected = popup
|
||||
.selected_mention()
|
||||
.map(|mention| mention.insert_text.clone());
|
||||
if let Some(insert_text) = selected {
|
||||
self.insert_selected_mention(&insert_text);
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
@@ -1135,6 +1153,19 @@ impl ChatComposer {
|
||||
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.
|
||||
@@ -1248,8 +1279,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)
|
||||
@@ -1306,7 +1337,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);
|
||||
@@ -1327,7 +1358,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);
|
||||
@@ -1376,9 +1407,11 @@ impl ChatComposer {
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let is_builtin = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
@@ -1495,9 +1528,11 @@ impl ChatComposer {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.find(|(n, _)| *n == name)
|
||||
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
Some(InputResult::Command(cmd))
|
||||
@@ -1517,9 +1552,11 @@ impl ChatComposer {
|
||||
if let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
@@ -1908,22 +1945,22 @@ 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 = file_token.is_none() && skill_token.is_none();
|
||||
let allow_command_popup = file_token.is_none() && mention_token.is_none();
|
||||
self.sync_command_popup(allow_command_popup);
|
||||
|
||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||
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 {
|
||||
self.sync_skill_popup(token);
|
||||
if let Some(token) = mention_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);
|
||||
@@ -1975,9 +2012,11 @@ impl ChatComposer {
|
||||
return rest_after_name.is_empty();
|
||||
}
|
||||
|
||||
let builtin_match =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
let builtin_match = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
|
||||
if builtin_match {
|
||||
return true;
|
||||
@@ -2031,11 +2070,13 @@ impl ChatComposer {
|
||||
if is_editing_slash_command_name {
|
||||
let skills_enabled = self.skills_enabled();
|
||||
let collaboration_modes_enabled = self.collaboration_modes_enabled;
|
||||
let connectors_enabled = self.connectors_enabled;
|
||||
let mut command_popup = CommandPopup::new(
|
||||
self.custom_prompts.clone(),
|
||||
CommandPopupFlags {
|
||||
skills_enabled,
|
||||
collaboration_modes_enabled,
|
||||
connectors_enabled,
|
||||
},
|
||||
);
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
@@ -2047,12 +2088,14 @@ impl ChatComposer {
|
||||
|
||||
fn built_in_slash_commands_for_input(
|
||||
collaboration_modes_enabled: bool,
|
||||
connectors_enabled: bool,
|
||||
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
|
||||
let allow_elevate_sandbox = windows_degraded_sandbox_active();
|
||||
built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
|
||||
.filter(move |(_, cmd)| connectors_enabled || *cmd != SlashCommand::Connectors)
|
||||
}
|
||||
|
||||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||
@@ -2098,32 +2141,95 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 search_terms = vec![display_name.clone(), connector.connector_id.clone()];
|
||||
mentions.push(MentionItem {
|
||||
display_name: display_name.clone(),
|
||||
description,
|
||||
insert_text: display_name.clone(),
|
||||
search_terms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mentions
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &ConnectorInfo) -> 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: &ConnectorInfo) -> Option<String> {
|
||||
connector
|
||||
.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;
|
||||
}
|
||||
@@ -2162,6 +2268,25 @@ 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())
|
||||
}
|
||||
|
||||
impl Renderable for ChatComposer {
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if !self.input_enabled {
|
||||
@@ -2322,6 +2447,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;
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(crate) struct CommandPopup {
|
||||
pub(crate) struct CommandPopupFlags {
|
||||
pub(crate) skills_enabled: bool,
|
||||
pub(crate) collaboration_modes_enabled: bool,
|
||||
pub(crate) connectors_enabled: bool,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
@@ -51,6 +52,7 @@ impl CommandPopup {
|
||||
.filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills)
|
||||
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
|
||||
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
@@ -448,6 +450,7 @@ mod tests {
|
||||
CommandPopupFlags {
|
||||
skills_enabled: false,
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
},
|
||||
);
|
||||
popup.on_composer_text_change("/collab".to_string());
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
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;
|
||||
@@ -185,6 +186,11 @@ 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 set_steer_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_steer_enabled(enabled);
|
||||
}
|
||||
@@ -194,6 +200,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_connectors_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
@@ -15,27 +15,34 @@ use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
|
||||
#[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) 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();
|
||||
}
|
||||
|
||||
@@ -62,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) {
|
||||
@@ -86,14 +93,14 @@ impl SkillPopup {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(idx, indices, _score)| {
|
||||
let skill = &self.skills[idx];
|
||||
let name = truncate_text(skill_display_name(skill), 21);
|
||||
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()),
|
||||
disabled_reason: None,
|
||||
wrap_indent: None,
|
||||
}
|
||||
@@ -106,27 +113,34 @@ 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);
|
||||
for (idx, mention) in self.mentions.iter().enumerate() {
|
||||
let display_name = mention.display_name.as_str();
|
||||
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
|
||||
out.push((idx, Some(indices), score));
|
||||
} else if display_name != skill.name
|
||||
&& let Some((_indices, score)) = fuzzy_match(&skill.name, filter)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut best_score: Option<i32> = None;
|
||||
for term in &mention.search_terms {
|
||||
if let Some((_indices, score)) = fuzzy_match(term, filter) {
|
||||
best_score = Some(best_score.map_or(score, |best| best.max(score)));
|
||||
}
|
||||
}
|
||||
if let Some(score) = best_score {
|
||||
out.push((idx, None, 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)
|
||||
})
|
||||
});
|
||||
@@ -155,7 +169,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 {
|
||||
@@ -173,25 +187,8 @@ 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(),
|
||||
])
|
||||
}
|
||||
|
||||
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) -> &str {
|
||||
skill
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.short_description.as_deref())
|
||||
.or(skill.short_description.as_deref())
|
||||
.unwrap_or(&skill.description)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -115,6 +116,7 @@ use tracing::debug;
|
||||
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
@@ -364,6 +366,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]
|
||||
@@ -437,6 +448,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
|
||||
@@ -608,6 +620,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 = Some(event.rollout_path.clone());
|
||||
@@ -632,6 +645,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);
|
||||
}
|
||||
@@ -1704,6 +1720,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(),
|
||||
@@ -1734,6 +1751,9 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.config.features.enabled(Feature::Connectors));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -1807,6 +1827,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(),
|
||||
@@ -1837,6 +1858,9 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.config.features.enabled(Feature::Connectors));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -2145,6 +2169,9 @@ impl ChatWidget {
|
||||
SlashCommand::Mcp => {
|
||||
self.add_mcp_output();
|
||||
}
|
||||
SlashCommand::Connectors => {
|
||||
self.add_connectors_output();
|
||||
}
|
||||
SlashCommand::Rollout => {
|
||||
if let Some(path) = self.rollout_path() {
|
||||
self.add_info_message(
|
||||
@@ -2733,6 +2760,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();
|
||||
|
||||
@@ -3966,6 +4015,17 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_steer_enabled(enabled);
|
||||
} else if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
} else if feature == Feature::Connectors {
|
||||
self.bottom_pane.set_connectors_enabled(enabled);
|
||||
if enabled {
|
||||
self.prefetch_connectors();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4036,6 +4096,10 @@ impl ChatWidget {
|
||||
self.config.features.enabled(Feature::CollaborationModes)
|
||||
}
|
||||
|
||||
fn connectors_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::Connectors)
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
|
||||
}
|
||||
@@ -4103,6 +4167,122 @@ 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 connectors feature to use $ or /apps.".to_string()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(self.connectors_cache, ConnectorsCacheState::Uninitialized) {
|
||||
self.prefetch_connectors();
|
||||
}
|
||||
let connectors_snapshot = match &self.connectors_cache {
|
||||
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.clone()),
|
||||
ConnectorsCacheState::Failed(err) => {
|
||||
self.add_to_history(history_cell::new_error_event(err.clone()));
|
||||
None
|
||||
}
|
||||
ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => {
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
"Apps are still loading.".to_string(),
|
||||
Some("Try again in a moment.".to_string()),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(snapshot) = connectors_snapshot {
|
||||
if snapshot.connectors.is_empty() {
|
||||
self.add_info_message("No apps available.".to_string(), None);
|
||||
} else {
|
||||
self.open_connectors_popup(&snapshot.connectors);
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn open_connectors_popup(&mut self, connectors: &[connectors::ConnectorInfo]) {
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
|
||||
for connector in connectors {
|
||||
let connector_label = connectors::connector_display_label(connector);
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.connector_id);
|
||||
let mut item = SelectionItem {
|
||||
name: connector_label,
|
||||
description: Some(description),
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
};
|
||||
if !connector.is_accessible {
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_install_instructions_event(install_url.clone()),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description =
|
||||
Some("Press Enter to view the install link.".to_string());
|
||||
} else {
|
||||
item.actions = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(
|
||||
"Install link unavailable.".to_string(),
|
||||
None,
|
||||
),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some("Install link unavailable.".to_string());
|
||||
}
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Apps".to_string()),
|
||||
subtitle: Some(
|
||||
"Browse apps. Select an unconnected app to view its install link.".to_string(),
|
||||
),
|
||||
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![
|
||||
"Use $ to insert apps. Press ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close.".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &connectors::ConnectorInfo) -> 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::ConnectorInfo) -> Option<String> {
|
||||
connector
|
||||
.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);
|
||||
@@ -4272,6 +4452,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();
|
||||
|
||||
|
||||
@@ -425,6 +425,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(),
|
||||
|
||||
@@ -1516,6 +1516,14 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_install_instructions_event(install_url: String) -> PrefixedWrappedHistoryCell {
|
||||
let instructions = Line::from("Install this app in your browser, then reload Codex.");
|
||||
let open_label = Line::from(vec!["Open:".dim()]);
|
||||
let url_line = Line::from(vec![install_url.cyan().underlined()]);
|
||||
let text = Text::from(vec![instructions, open_label, url_line]);
|
||||
PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ")
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
// Use a hair space (U+200A) to create a subtle, near-invisible separation
|
||||
// before the text. VS16 is intentionally omitted to keep spacing tighter
|
||||
|
||||
@@ -30,6 +30,8 @@ pub enum SlashCommand {
|
||||
Mention,
|
||||
Status,
|
||||
Mcp,
|
||||
#[strum(serialize = "apps")]
|
||||
Connectors,
|
||||
Logout,
|
||||
Quit,
|
||||
Exit,
|
||||
@@ -63,6 +65,7 @@ impl SlashCommand {
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::Experimental => "toggle beta features",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Connectors => "browse app connectors",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Rollout => "print the rollout file path",
|
||||
SlashCommand::TestApproval => "test approval request",
|
||||
@@ -96,6 +99,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Connectors
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::Exit => true,
|
||||
|
||||
@@ -31,6 +31,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",
|
||||
@@ -86,6 +87,7 @@ tokio = { workspace = true, features = [
|
||||
] }
|
||||
tokio-stream = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
toml_edit = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -1694,6 +1694,12 @@ 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::InsertComposerText(text) => {
|
||||
self.chat_widget.insert_composer_text(&text);
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.on_update_reasoning_effort(effort);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_chatgpt::connectors::ConnectorInfo;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
@@ -36,6 +37,11 @@ pub(crate) enum WindowsSandboxFallbackReason {
|
||||
ElevationFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ConnectorsSnapshot {
|
||||
pub(crate) connectors: Vec<ConnectorInfo>,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
@@ -81,6 +87,12 @@ pub(crate) enum AppEvent {
|
||||
/// Result of refreshing rate limits
|
||||
RateLimitSnapshotFetched(RateLimitSnapshot),
|
||||
|
||||
/// Result of prefetching connectors.
|
||||
ConnectorsLoaded(Result<ConnectorsSnapshot, String>),
|
||||
|
||||
/// Insert text into the chat composer.
|
||||
InsertComposerText(String),
|
||||
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ pub(crate) struct ChatComposer {
|
||||
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
|
||||
steer_enabled: bool,
|
||||
collaboration_modes_enabled: bool,
|
||||
connectors_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -272,6 +273,7 @@ impl ChatComposer {
|
||||
dismissed_skill_popup_token: None,
|
||||
steer_enabled: false,
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -296,6 +298,10 @@ impl ChatComposer {
|
||||
self.collaboration_modes_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||||
self.connectors_enabled = enabled;
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
@@ -1309,9 +1315,11 @@ impl ChatComposer {
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
if !treat_as_plain_text {
|
||||
let is_builtin =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let is_builtin = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.any(|(command_name, _)| command_name == name);
|
||||
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
|
||||
let is_known_prompt = name
|
||||
.strip_prefix(&prompt_prefix)
|
||||
@@ -1428,9 +1436,11 @@ impl ChatComposer {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
&& let Some((_n, cmd)) =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.find(|(n, _)| *n == name)
|
||||
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
Some(InputResult::Command(cmd))
|
||||
@@ -1450,9 +1460,11 @@ impl ChatComposer {
|
||||
if let Some((name, rest)) = parse_slash_name(&text)
|
||||
&& !rest.is_empty()
|
||||
&& !name.contains('/')
|
||||
&& let Some((_n, cmd)) =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
&& cmd == SlashCommand::Review
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
@@ -1944,9 +1956,11 @@ impl ChatComposer {
|
||||
return rest_after_name.is_empty();
|
||||
}
|
||||
|
||||
let builtin_match =
|
||||
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
let builtin_match = Self::built_in_slash_commands_for_input(
|
||||
self.collaboration_modes_enabled,
|
||||
self.connectors_enabled,
|
||||
)
|
||||
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
|
||||
|
||||
if builtin_match {
|
||||
return true;
|
||||
@@ -2000,11 +2014,13 @@ impl ChatComposer {
|
||||
if is_editing_slash_command_name {
|
||||
let skills_enabled = self.skills_enabled();
|
||||
let collaboration_modes_enabled = self.collaboration_modes_enabled;
|
||||
let connectors_enabled = self.connectors_enabled;
|
||||
let mut command_popup = CommandPopup::new(
|
||||
self.custom_prompts.clone(),
|
||||
CommandPopupFlags {
|
||||
skills_enabled,
|
||||
collaboration_modes_enabled,
|
||||
connectors_enabled,
|
||||
},
|
||||
);
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
@@ -2016,12 +2032,14 @@ impl ChatComposer {
|
||||
|
||||
fn built_in_slash_commands_for_input(
|
||||
collaboration_modes_enabled: bool,
|
||||
connectors_enabled: bool,
|
||||
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
|
||||
let allow_elevate_sandbox = windows_degraded_sandbox_active();
|
||||
built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
|
||||
.filter(move |(_, cmd)| connectors_enabled || *cmd != SlashCommand::Connectors)
|
||||
}
|
||||
|
||||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(crate) struct CommandPopup {
|
||||
pub(crate) struct CommandPopupFlags {
|
||||
pub(crate) skills_enabled: bool,
|
||||
pub(crate) collaboration_modes_enabled: bool,
|
||||
pub(crate) connectors_enabled: bool,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
@@ -51,6 +52,7 @@ impl CommandPopup {
|
||||
.filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills)
|
||||
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
|
||||
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors)
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
@@ -437,6 +439,7 @@ mod tests {
|
||||
CommandPopupFlags {
|
||||
skills_enabled: false,
|
||||
collaboration_modes_enabled: true,
|
||||
connectors_enabled: false,
|
||||
},
|
||||
);
|
||||
popup.on_composer_text_change("/collab".to_string());
|
||||
|
||||
@@ -186,6 +186,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_connectors_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_connectors_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use std::time::Instant;
|
||||
|
||||
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;
|
||||
@@ -111,6 +112,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
@@ -317,6 +319,15 @@ enum RateLimitSwitchPromptState {
|
||||
Shown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
enum ConnectorsCacheState {
|
||||
#[default]
|
||||
Uninitialized,
|
||||
Loading,
|
||||
Ready(ConnectorsSnapshot),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
type CollaborationModeSelection = collaboration_modes::Selection;
|
||||
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
@@ -380,6 +391,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
|
||||
@@ -554,6 +566,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);
|
||||
}
|
||||
@@ -1507,6 +1522,7 @@ impl ChatWidget {
|
||||
task_complete_pending: false,
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -1536,6 +1552,9 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.config.features.enabled(Feature::Connectors));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -1608,6 +1627,7 @@ impl ChatWidget {
|
||||
task_complete_pending: false,
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -1637,6 +1657,9 @@ impl ChatWidget {
|
||||
widget.bottom_pane.set_collaboration_modes_enabled(
|
||||
widget.config.features.enabled(Feature::CollaborationModes),
|
||||
);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_connectors_enabled(widget.config.features.enabled(Feature::Connectors));
|
||||
|
||||
widget
|
||||
}
|
||||
@@ -1914,6 +1937,9 @@ impl ChatWidget {
|
||||
SlashCommand::Mcp => {
|
||||
self.add_mcp_output();
|
||||
}
|
||||
SlashCommand::Connectors => {
|
||||
self.add_connectors_output();
|
||||
}
|
||||
SlashCommand::Rollout => {
|
||||
if let Some(path) = self.rollout_path() {
|
||||
self.add_info_message(
|
||||
@@ -2492,6 +2518,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();
|
||||
|
||||
@@ -3658,9 +3706,10 @@ impl ChatWidget {
|
||||
}
|
||||
if feature == Feature::Steer {
|
||||
self.bottom_pane.set_steer_enabled(enabled);
|
||||
}
|
||||
if feature == Feature::CollaborationModes {
|
||||
} else if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
} else if feature == Feature::Connectors {
|
||||
self.bottom_pane.set_connectors_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3706,6 +3755,10 @@ impl ChatWidget {
|
||||
self.config.features.enabled(Feature::CollaborationModes)
|
||||
}
|
||||
|
||||
fn connectors_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::Connectors)
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
|
||||
}
|
||||
@@ -3797,6 +3850,114 @@ 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 connectors feature to use /apps.".to_string()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(self.connectors_cache, ConnectorsCacheState::Uninitialized) {
|
||||
self.prefetch_connectors();
|
||||
}
|
||||
let connectors_snapshot = match &self.connectors_cache {
|
||||
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.clone()),
|
||||
ConnectorsCacheState::Failed(err) => {
|
||||
self.add_to_history(history_cell::new_error_event(err.clone()));
|
||||
None
|
||||
}
|
||||
ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => {
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
"Apps are still loading.".to_string(),
|
||||
Some("Try again in a moment.".to_string()),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(snapshot) = connectors_snapshot {
|
||||
if snapshot.connectors.is_empty() {
|
||||
self.add_info_message("No apps available.".to_string(), None);
|
||||
} else {
|
||||
self.open_connectors_popup(&snapshot.connectors);
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn open_connectors_popup(&mut self, connectors: &[connectors::ConnectorInfo]) {
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
|
||||
for connector in connectors {
|
||||
let connector_label = connectors::connector_display_label(connector);
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.connector_id);
|
||||
let mut item = SelectionItem {
|
||||
name: connector_label.clone(),
|
||||
description: Some(description),
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
};
|
||||
if connector.is_accessible {
|
||||
let insert_text = format!("{connector_label} connector");
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertComposerText(insert_text.clone()));
|
||||
})];
|
||||
item.actions = actions;
|
||||
item.dismiss_on_select = true;
|
||||
} else if let Some(install_url) = connector.install_url.clone() {
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_install_instructions_event(install_url.clone()),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description =
|
||||
Some("Press Enter to view the install link.".to_string());
|
||||
} else {
|
||||
item.actions = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event("Install link unavailable.".to_string(), None),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some("Install link unavailable.".to_string());
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Apps".to_string()),
|
||||
subtitle: Some("Select an app to insert it or view install instructions.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search apps".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &connectors::ConnectorInfo) -> 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::ConnectorInfo) -> Option<String> {
|
||||
connector
|
||||
.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);
|
||||
@@ -3926,6 +4087,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_composer_text(text);
|
||||
}
|
||||
|
||||
/// Insert text into the composer at the current cursor position.
|
||||
pub(crate) fn insert_composer_text(&mut self, text: &str) {
|
||||
self.bottom_pane.insert_str(text);
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.bottom_pane.show_esc_backtrack_hint();
|
||||
}
|
||||
@@ -3994,6 +4160,13 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ async fn make_chatwidget_manual(
|
||||
task_complete_pending: false,
|
||||
agent_turn_running: false,
|
||||
mcp_startup_status: None,
|
||||
connectors_cache: ConnectorsCacheState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
|
||||
@@ -1580,6 +1580,14 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_install_instructions_event(install_url: String) -> PrefixedWrappedHistoryCell {
|
||||
let instructions = Line::from("Install this app in your browser, then reload Codex.");
|
||||
let open_label = Line::from(vec!["Open:".dim()]);
|
||||
let url_line = Line::from(vec![install_url.cyan().underlined()]);
|
||||
let text = Text::from(vec![instructions, open_label, url_line]);
|
||||
PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ")
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
// Use a hair space (U+200A) to create a subtle, near-invisible separation
|
||||
// before the text. VS16 is intentionally omitted to keep spacing tighter
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum SlashCommand {
|
||||
Mention,
|
||||
Status,
|
||||
Mcp,
|
||||
#[strum(serialize = "apps")]
|
||||
Connectors,
|
||||
Logout,
|
||||
Quit,
|
||||
Exit,
|
||||
@@ -59,6 +61,7 @@ impl SlashCommand {
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Connectors => "insert an app connector",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Rollout => "print the rollout file path",
|
||||
SlashCommand::TestApproval => "test approval request",
|
||||
@@ -90,6 +93,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Connectors
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::Exit => true,
|
||||
|
||||
@@ -12,6 +12,19 @@ Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the c
|
||||
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
|
||||
## Apps (Connectors)
|
||||
|
||||
Use `$` in the composer to insert a ChatGPT connector; the popover lists accessible
|
||||
apps. Connected apps appear first and are labeled as connected; others are marked
|
||||
as can be installed. The `/apps` command still lists apps, but it is read-only.
|
||||
Inserting an app adds `<connector name>` to the composer. Apps are exposed
|
||||
through the built-in `codex_apps_mcp` MCP server when the connectors feature is
|
||||
enabled. Only connectors inserted into the conversation are enabled as tools, and
|
||||
there is no connectors-specific configuration in `config.toml`. By default, the
|
||||
built-in server uses the same bearer token as other Codex backend requests. To
|
||||
override it, set the `CODEX_CONNECTORS_TOKEN` environment variable to a non-empty
|
||||
value.
|
||||
|
||||
## Notify
|
||||
|
||||
Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings:
|
||||
|
||||
Reference in New Issue
Block a user