Compare commits

...

11 Commits

Author SHA1 Message Date
Matthew Zeng
ac4dff66e4 update 2026-01-22 23:24:43 -08:00
Matthew Zeng
8266055b1f update 2026-01-22 21:03:23 -08:00
Matthew Zeng
81329e27d4 update 2026-01-22 17:51:29 -08:00
Matthew Zeng
2695137710 update 2026-01-22 17:41:39 -08:00
Matthew Zeng
5092039f95 update 2026-01-22 17:10:30 -08:00
Matthew Zeng
427a5d7b99 update 2026-01-22 17:07:55 -08:00
Matthew Zeng
5a300301af Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/connectors_codex_cli_4 2026-01-22 17:07:48 -08:00
Matthew Zeng
0b20959c0f update 2026-01-21 22:51:57 -08:00
Matthew Zeng
b5a37c2bd3 Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/connectors_codex_cli_1 2026-01-21 22:27:44 -08:00
Matthew Zeng
9a69a97e33 Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/connectors_codex_cli_1 2026-01-21 21:12:54 -08:00
Matthew Zeng
4d25fe57b3 update 2026-01-21 17:37:09 -08:00
20 changed files with 729 additions and 174 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1753,6 +1753,7 @@ dependencies = [
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
"codex-cli",
"codex-common",
"codex-core",

View File

@@ -13,7 +13,6 @@ use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::AppInfo as ApiAppInfo;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::ArchiveConversationParams;
@@ -3486,18 +3485,7 @@ impl CodexMessageProcessor {
}
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end]
.iter()
.cloned()
.map(|connector| ApiAppInfo {
id: connector.connector_id,
name: connector.connector_name,
description: connector.connector_description,
logo_url: connector.logo_url,
install_url: connector.install_url,
is_accessible: connector.is_accessible,
})
.collect();
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())

View File

@@ -20,7 +20,7 @@ use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::connectors::ConnectorInfo;
use codex_core::auth::login_with_api_key;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::JsonObject;
@@ -68,21 +68,68 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn list_apps_returns_empty_when_using_api_key() -> Result<()> {
let connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
install_url: None,
is_accessible: false,
}];
let tools = vec![connector_tool("alpha", "Alpha App")?];
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: Some(50),
cursor: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert!(data.is_empty());
assert!(next_cursor.is_none());
server_handle.abort();
Ok(())
}
#[tokio::test]
async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
let connectors = vec![
ConnectorInfo {
connector_id: "alpha".to_string(),
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
install_url: None,
is_accessible: false,
},
ConnectorInfo {
connector_id: "beta".to_string(),
connector_name: "beta".to_string(),
connector_description: None,
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
install_url: None,
is_accessible: false,
@@ -150,18 +197,18 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
#[tokio::test]
async fn list_apps_paginates_results() -> Result<()> {
let connectors = vec![
ConnectorInfo {
connector_id: "alpha".to_string(),
connector_name: "Alpha".to_string(),
connector_description: Some("Alpha connector".to_string()),
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
install_url: None,
is_accessible: false,
},
ConnectorInfo {
connector_id: "beta".to_string(),
connector_name: "beta".to_string(),
connector_description: None,
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
install_url: None,
is_accessible: false,
@@ -289,7 +336,7 @@ impl ServerHandler for AppListMcpServer {
}
async fn start_apps_server(
connectors: Vec<ConnectorInfo>,
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
let state = AppsServerState {

View File

@@ -1,3 +1,5 @@
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::features::Feature;
use serde::Deserialize;
@@ -7,7 +9,7 @@ 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::AppInfo;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
@@ -33,11 +35,24 @@ enum PrincipalType {
#[derive(Debug, Deserialize)]
struct ListConnectorsResponse {
connectors: Vec<ConnectorInfo>,
connectors: Vec<AppInfo>,
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
async fn connectors_allowed(config: &Config) -> bool {
if !config.features.enabled(Feature::Connectors) {
return false;
}
let auth_manager = AuthManager::new(
config.codex_home.clone(),
false,
config.cli_auth_credentials_store_mode,
);
let auth = auth_manager.auth().await;
!auth.as_ref().is_some_and(CodexAuth::is_api_key)
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !connectors_allowed(config).await {
return Ok(Vec::new());
}
let (connectors_result, accessible_result) = tokio::join!(
@@ -49,8 +64,8 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInf
Ok(merge_connectors(connectors, accessible))
}
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
if !config.features.enabled(Feature::Connectors) {
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !connectors_allowed(config).await {
return Ok(Vec::new());
}
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
@@ -91,19 +106,17 @@ pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<Connecto
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.connector_name, &connector.connector_id),
None => connector_install_url(&connector.name, &connector.id),
};
connector.connector_name =
normalize_connector_name(&connector.connector_name, &connector.connector_id);
connector.connector_description =
normalize_connector_value(connector.connector_description.as_deref());
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.connector_name
.cmp(&right.connector_name)
.then_with(|| left.connector_id.cmp(&right.connector_id))
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(connectors)
}

View File

@@ -125,6 +125,10 @@ impl CodexAuth {
self.get_current_token_data().and_then(|t| t.id_token.email)
}
pub fn is_api_key(&self) -> bool {
self.mode == AuthMode::ApiKey
}
/// Account-facing plan classification derived from the current token.
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// mapped from the ID token's internal plan value. Prefer this when you

View File

@@ -3024,9 +3024,9 @@ async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>)
}
fn filter_connectors_for_input(
connectors: Vec<connectors::ConnectorInfo>,
connectors: Vec<connectors::AppInfo>,
input: &[ResponseItem],
) -> Vec<connectors::ConnectorInfo> {
) -> Vec<connectors::AppInfo> {
let user_messages = collect_user_messages(input);
if user_messages.is_empty() {
return Vec::new();
@@ -3039,7 +3039,7 @@ fn filter_connectors_for_input(
}
fn connector_inserted_in_messages(
connector: &connectors::ConnectorInfo,
connector: &connectors::AppInfo,
user_messages: &[String],
) -> bool {
let label = connectors::connector_display_label(connector);
@@ -3053,11 +3053,11 @@ fn connector_inserted_in_messages(
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
connectors: &[connectors::ConnectorInfo],
connectors: &[connectors::AppInfo],
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.connector_id.as_str())
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {

View File

@@ -3,12 +3,12 @@ use std::env;
use std::path::PathBuf;
use async_channel::unbounded;
pub use codex_app_server_protocol::AppInfo;
use codex_protocol::protocol::SandboxPolicy;
use serde::Deserialize;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use crate::AuthManager;
use crate::CodexAuth;
use crate::SandboxState;
use crate::config::Config;
use crate::features::Feature;
@@ -17,31 +17,18 @@ 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>> {
) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Connectors) {
return Ok(Vec::new());
}
let auth_manager = auth_manager_from_config(config);
let auth = auth_manager.auth().await;
if auth.as_ref().is_some_and(CodexAuth::is_api_key) {
return Ok(Vec::new());
}
let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref(), config);
if mcp_servers.is_empty() {
return Ok(Vec::new());
@@ -86,13 +73,13 @@ fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
)
}
pub fn connector_display_label(connector: &ConnectorInfo) -> String {
format_connector_label(&connector.connector_name, &connector.connector_id)
pub fn connector_display_label(connector: &AppInfo) -> String {
format_connector_label(&connector.name, &connector.id)
}
pub(crate) fn accessible_connectors_from_mcp_tools(
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
) -> Vec<ConnectorInfo> {
) -> Vec<AppInfo> {
let tools = mcp_tools.values().filter_map(|tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return None;
@@ -105,30 +92,27 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
}
pub fn merge_connectors(
connectors: Vec<ConnectorInfo>,
accessible_connectors: Vec<ConnectorInfo>,
) -> Vec<ConnectorInfo> {
let mut merged: HashMap<String, ConnectorInfo> = connectors
connectors: Vec<AppInfo>,
accessible_connectors: Vec<AppInfo>,
) -> Vec<AppInfo> {
let mut merged: HashMap<String, AppInfo> = connectors
.into_iter()
.map(|mut connector| {
connector.is_accessible = false;
(connector.connector_id.clone(), connector)
(connector.id.clone(), connector)
})
.collect();
for mut connector in accessible_connectors {
connector.is_accessible = true;
let connector_id = connector.connector_id.clone();
let connector_id = connector.id.clone();
if let Some(existing) = merged.get_mut(&connector_id) {
existing.is_accessible = true;
if existing.connector_name == existing.connector_id
&& connector.connector_name != connector.connector_id
{
existing.connector_name = connector.connector_name;
if existing.name == existing.id && connector.name != connector.id {
existing.name = connector.name;
}
if existing.connector_description.is_none() && connector.connector_description.is_some()
{
existing.connector_description = connector.connector_description;
if existing.description.is_none() && connector.description.is_some() {
existing.description = connector.description;
}
if existing.logo_url.is_none() && connector.logo_url.is_some() {
existing.logo_url = connector.logo_url;
@@ -141,23 +125,20 @@ pub fn merge_connectors(
let mut merged = merged.into_values().collect::<Vec<_>>();
for connector in &mut merged {
if connector.install_url.is_none() {
connector.install_url = Some(connector_install_url(
&connector.connector_name,
&connector.connector_id,
));
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
}
}
merged.sort_by(|left, right| {
right
.is_accessible
.cmp(&left.is_accessible)
.then_with(|| left.connector_name.cmp(&right.connector_name))
.then_with(|| left.connector_id.cmp(&right.connector_id))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
merged
}
fn collect_accessible_connectors<I>(tools: I) -> Vec<ConnectorInfo>
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>)>,
{
@@ -172,14 +153,14 @@ where
connectors.insert(connector_id, connector_name);
}
}
let mut accessible: Vec<ConnectorInfo> = connectors
let mut accessible: Vec<AppInfo> = connectors
.into_iter()
.map(|(connector_id, connector_name)| ConnectorInfo {
install_url: Some(connector_install_url(&connector_name, &connector_id)),
connector_id,
connector_name,
connector_description: None,
.map(|(connector_id, connector_name)| AppInfo {
id: connector_id.clone(),
name: connector_name.clone(),
description: None,
logo_url: None,
install_url: Some(connector_install_url(&connector_name, &connector_id)),
is_accessible: true,
})
.collect();
@@ -187,8 +168,8 @@ where
right
.is_accessible
.cmp(&left.is_accessible)
.then_with(|| left.connector_name.cmp(&right.connector_name))
.then_with(|| left.connector_id.cmp(&right.connector_id))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
accessible
}

View File

@@ -100,13 +100,17 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
}
}
fn connectors_allowed(connectors_enabled: bool, auth: Option<&CodexAuth>) -> bool {
connectors_enabled && !auth.is_some_and(CodexAuth::is_api_key)
}
pub(crate) fn with_codex_apps_mcp(
mut servers: HashMap<String, McpServerConfig>,
connectors_enabled: bool,
auth: Option<&CodexAuth>,
config: &Config,
) -> HashMap<String, McpServerConfig> {
if connectors_enabled {
if connectors_allowed(connectors_enabled, auth) {
servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(config, auth),

View File

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

View File

@@ -1032,6 +1032,14 @@ impl App {
));
tui.frame_requester().schedule_frame();
}
AppEvent::OpenAppLink {
title,
instructions,
url,
} => {
self.chat_widget
.open_app_link_view(title, instructions, url);
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
self.file_search.on_user_query(query);
@@ -1043,6 +1051,9 @@ impl App {
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
AppEvent::ConnectorsLoaded(result) => {
self.chat_widget.on_connectors_loaded(result);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
}

View File

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

View File

@@ -0,0 +1,123 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use crate::wrapping::word_wrap_lines;
pub(crate) struct AppLinkView {
title: String,
instructions: String,
url: String,
complete: bool,
}
impl AppLinkView {
pub(crate) fn new(title: String, instructions: String, url: String) -> Self {
Self {
title,
instructions,
url,
complete: false,
}
}
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(self.title.clone().bold()));
lines.push(Line::from(""));
let instructions = self.instructions.trim();
if !instructions.is_empty() {
for line in wrap(instructions, usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
lines.push(Line::from(vec!["Open:".dim()]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
lines.extend(word_wrap_lines(vec![url_line], usable_width));
lines
}
}
impl BottomPaneView for AppLinkView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let KeyEvent {
code: KeyCode::Esc, ..
} = key_event
{
self.on_ctrl_c();
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
}
impl crate::render::renderable::Renderable for AppLinkView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4).max(1);
let content_lines = self.content_lines(content_width);
content_lines.len() as u16 + 3
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Block::default()
.style(user_message_style())
.render(area, buf);
let [content_area, hint_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let inner = content_area.inset(Insets::vh(1, 2));
let content_width = inner.width.max(1);
let lines = self.content_lines(content_width);
Paragraph::new(lines).render(inner, buf);
if hint_area.height > 0 {
let hint_area = Rect {
x: hint_area.x.saturating_add(2),
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
hint_line().dim().render(hint_area, buf);
}
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}

View File

@@ -3,7 +3,7 @@
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
@@ -108,6 +108,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;
@@ -130,6 +131,7 @@ use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::textarea::TextArea;
@@ -138,6 +140,8 @@ use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_chatgpt::connectors;
use codex_chatgpt::connectors::AppInfo;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@@ -229,11 +233,13 @@ 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,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
}
#[derive(Clone, Debug)]
@@ -291,10 +297,12 @@ 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,
collaboration_mode_indicator: None,
connectors_enabled: false,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -305,6 +313,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
@@ -326,6 +338,10 @@ impl ChatComposer {
self.collaboration_mode_indicator = indicator;
}
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
@@ -1243,8 +1259,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)
@@ -1257,9 +1273,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)
@@ -1378,14 +1396,23 @@ impl ChatComposer {
(rebuilt, rebuilt_elements)
}
fn skills_enabled(&self) -> bool {
self.skills.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.skills.as_ref()
}
fn mentions_enabled(&self) -> bool {
let skills_ready = self
.skills
.as_ref()
.is_some_and(|skills| !skills.is_empty());
let connectors_ready = self.connectors_enabled
&& self
.connectors_snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
skills_ready || connectors_ready
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
@@ -1499,8 +1526,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)
@@ -1558,7 +1585,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);
@@ -1579,7 +1606,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);
@@ -1627,9 +1654,11 @@ impl ChatComposer {
if let Some((name, _rest, _rest_offset)) = 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)
@@ -1794,9 +1823,11 @@ impl ChatComposer {
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = 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_clearing_elements("");
Some(InputResult::Command(cmd))
@@ -1816,9 +1847,11 @@ impl ChatComposer {
if let Some((name, rest, _rest_offset)) = 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_clearing_elements("");
@@ -2214,22 +2247,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);
@@ -2281,9 +2314,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;
@@ -2336,10 +2371,12 @@ impl ChatComposer {
_ => {
if is_editing_slash_command_name {
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 {
collaboration_modes_enabled,
connectors_enabled,
},
);
command_popup.on_composer_text_change(first_line.to_string());
@@ -2351,12 +2388,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::Apps)
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
@@ -2402,32 +2441,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.id.clone()];
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: display_name.clone(),
search_terms,
});
}
}
mentions
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} - {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
@@ -2466,6 +2568,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 {
@@ -2644,6 +2765,7 @@ mod tests {
use tempfile::tempdir;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;

View File

@@ -39,6 +39,7 @@ pub(crate) struct CommandPopup {
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
}
impl CommandPopup {
@@ -48,6 +49,7 @@ impl CommandPopup {
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.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();
@@ -466,6 +468,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
},
);
popup.on_composer_text_change("/collab".to_string());

View File

@@ -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;
@@ -36,6 +37,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::time::Duration;
mod app_link_view;
mod approval_overlay;
mod request_user_input;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -108,6 +110,7 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
@@ -198,6 +201,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);
}
@@ -207,6 +215,10 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.composer.set_connectors_enabled(enabled);
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,

View File

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

View File

@@ -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;
@@ -125,6 +126,7 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_EXECUTE_MESSAGE: &str = "Implement the plan.";
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
@@ -378,6 +380,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]
@@ -452,6 +463,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
@@ -724,6 +736,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());
@@ -759,6 +772,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);
}
@@ -794,6 +810,12 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn open_app_link_view(&mut self, title: String, instructions: String, url: String) {
let view = crate::bottom_pane::AppLinkView::new(title, instructions, url);
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
let params = crate::bottom_pane::feedback_upload_consent_params(
self.app_event_tx.clone(),
@@ -1993,6 +2015,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(),
@@ -2027,6 +2050,10 @@ impl ChatWidget {
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget
}
@@ -2113,6 +2140,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(),
@@ -2481,6 +2509,9 @@ impl ChatWidget {
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
@@ -3075,6 +3106,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();
@@ -4559,6 +4612,11 @@ impl ChatWidget {
self.request_redraw();
}
fn connectors_enabled(&self) -> bool {
self.config.features.enabled(Feature::Connectors)
&& self.auth_manager.get_auth_mode() != Some(AuthMode::ApiKey)
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(
config: &Config,
@@ -4628,6 +4686,135 @@ 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::AppInfo]) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
for connector in connectors {
let connector_label = connectors::connector_display_label(connector);
let connector_title = connector_label.clone();
let description = Self::connector_brief_description(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
description: Some(description),
search_value: Some(search_value),
..Default::default()
};
let (selected_label, missing_label, instructions) = if connector.is_accessible {
(
"Press Enter to view the app link.",
"App link unavailable.",
"Open this app's connector page in your browser.",
)
} else {
(
"Press Enter to view the install link.",
"Install link unavailable.",
"Install this app in your browser, then reload Codex.",
)
};
if let Some(install_url) = connector.install_url.clone() {
let title = connector_title.clone();
let instructions = instructions.to_string();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
title: title.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
});
})];
item.dismiss_on_select = true;
item.selected_description = Some(selected_label.to_string());
} else {
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(missing_label.to_string(), None),
)));
})];
item.dismiss_on_select = true;
item.selected_description = Some(missing_label.to_string());
}
items.push(item);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Apps".to_string()),
subtitle: Some(
"Use $ to insert an app in the prompt. Browse apps to view connector links."
.to_string(),
),
footer_hint: Some(Self::connectors_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
..Default::default()
});
}
fn connectors_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close.".into(),
])
}
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
/// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
@@ -4814,6 +5001,19 @@ impl ChatWidget {
self.set_skills_from_response(&ev);
}
pub(crate) fn on_connectors_loaded(&mut self, result: Result<ConnectorsSnapshot, String>) {
self.connectors_cache = match result {
Ok(connectors) => ConnectorsCacheState::Ready(connectors),
Err(err) => ConnectorsCacheState::Failed(err),
};
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache {
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else {
self.bottom_pane.set_connectors_snapshot(None);
}
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();

View File

@@ -822,6 +822,7 @@ async fn make_chatwidget_manual(
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),

View File

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

View File

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