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

- [x] Support `/apps` slash command to browse the apps in tui.
- [x] Support inserting apps to prompt using `$`.
- [x] Lots of simplification/renaming from connectors to apps.
This commit is contained in:
Matthew Zeng
2026-01-28 19:51:58 -08:00
committed by GitHub
parent ecc66f4f52
commit b9cd089d1f
36 changed files with 2025 additions and 362 deletions
+1
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",
+18
View File
@@ -1508,6 +1508,21 @@ impl App {
));
tui.frame_requester().schedule_frame();
}
AppEvent::OpenAppLink {
title,
description,
instructions,
url,
is_installed,
} => {
self.chat_widget.open_app_link_view(
title,
description,
instructions,
url,
is_installed,
);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -1517,6 +1532,9 @@ impl App {
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
AppEvent::ConnectorsLoaded(result) => {
self.chat_widget.on_connectors_loaded(result);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
}
+18
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_chatgpt::connectors::AppInfo;
use codex_common::approval_presets::ApprovalPreset;
use codex_core::protocol::Event;
use codex_core::protocol::RateLimitSnapshot;
@@ -40,6 +41,11 @@ pub(crate) enum WindowsSandboxFallbackReason {
ElevationFailed,
}
#[derive(Debug, Clone)]
pub(crate) struct ConnectorsSnapshot {
pub(crate) connectors: Vec<AppInfo>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -89,9 +95,21 @@ pub(crate) enum AppEvent {
/// Result of refreshing rate limits
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
ConnectorsLoaded(Result<ConnectorsSnapshot, String>),
/// Result of computing a `/diff` command.
DiffResult(String),
/// Open the app link view in the bottom pane.
OpenAppLink {
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
},
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,
@@ -0,0 +1,163 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use crate::wrapping::word_wrap_lines;
pub(crate) struct AppLinkView {
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
complete: bool,
}
impl AppLinkView {
pub(crate) fn new(
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) -> Self {
Self {
title,
description,
instructions,
url,
is_installed,
complete: false,
}
}
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(self.title.clone().bold()));
if let Some(description) = self
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
for line in wrap(description, usable_width) {
lines.push(Line::from(line.into_owned().dim()));
}
}
lines.push(Line::from(""));
if self.is_installed {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
let instructions = self.instructions.trim();
if !instructions.is_empty() {
for line in wrap(instructions, usable_width) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
}
lines.push(Line::from(vec!["Open:".dim()]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
lines.extend(word_wrap_lines(vec![url_line], usable_width));
lines
}
}
impl BottomPaneView for AppLinkView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let KeyEvent {
code: KeyCode::Esc, ..
} = key_event
{
self.on_ctrl_c();
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
}
impl crate::render::renderable::Renderable for AppLinkView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4).max(1);
let content_lines = self.content_lines(content_width);
content_lines.len() as u16 + 3
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Block::default()
.style(user_message_style())
.render(area, buf);
let [content_area, hint_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
let inner = content_area.inset(Insets::vh(1, 2));
let content_width = inner.width.max(1);
let lines = self.content_lines(content_width);
Paragraph::new(lines).render(inner, buf);
if hint_area.height > 0 {
let hint_area = Rect {
x: hint_area.x.saturating_add(2),
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
hint_line().dim().render(hint_area, buf);
}
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}
+202 -36
View File
@@ -3,7 +3,7 @@
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions).
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
@@ -124,6 +124,7 @@ use super::footer::single_line_footer_layout;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands;
use crate::bottom_pane::paste_burst::FlushResult;
@@ -146,6 +147,7 @@ use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::textarea::TextArea;
@@ -154,6 +156,8 @@ use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_chatgpt::connectors;
use codex_chatgpt::connectors::AppInfo;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@@ -276,12 +280,15 @@ pub(crate) struct ChatComposer {
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
connectors_snapshot: Option<ConnectorsSnapshot>,
dismissed_mention_popup_token: Option<String>,
mention_paths: HashMap<String, String>,
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
collaboration_modes_enabled: bool,
config: ChatComposerConfig,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
connectors_enabled: bool,
personality_command_enabled: bool,
windows_degraded_sandbox_active: bool,
}
@@ -363,11 +370,14 @@ impl ChatComposer {
context_window_percent: None,
context_window_used_tokens: None,
skills: None,
dismissed_skill_popup_token: None,
connectors_snapshot: None,
dismissed_mention_popup_token: None,
mention_paths: HashMap::new(),
steer_enabled: false,
collaboration_modes_enabled: false,
config,
collaboration_mode_indicator: None,
connectors_enabled: false,
personality_command_enabled: false,
windows_degraded_sandbox_active: false,
};
@@ -380,6 +390,14 @@ impl ChatComposer {
self.skills = skills;
}
pub fn set_connector_mentions(&mut self, connectors_snapshot: Option<ConnectorsSnapshot>) {
self.connectors_snapshot = connectors_snapshot;
}
pub(crate) fn take_mention_paths(&mut self) -> HashMap<String, String> {
std::mem::take(&mut self.mention_paths)
}
/// Enables or disables "Steer" behavior for submission keys.
///
/// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and
@@ -394,6 +412,10 @@ impl ChatComposer {
self.collaboration_modes_enabled = enabled;
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.connectors_enabled = enabled;
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
@@ -697,6 +719,7 @@ impl ChatComposer {
self.textarea.set_text_clearing_elements("");
self.pending_pastes.clear();
self.attached_images.clear();
self.mention_paths.clear();
self.textarea.set_text_with_elements(&text, &text_elements);
@@ -1349,7 +1372,10 @@ impl ChatComposer {
unreachable!();
};
match key_event {
let mut selected_mention: Option<(String, Option<String>)> = None;
let mut close_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
@@ -1376,8 +1402,8 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(tok) = self.current_skill_token() {
self.dismissed_skill_popup_token = Some(tok);
if let Some(tok) = self.current_mention_token() {
self.dismissed_mention_popup_token = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
@@ -1390,15 +1416,26 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
let selected = popup.selected_skill().map(|skill| skill.name.clone());
if let Some(name) = selected {
self.insert_selected_skill(&name);
if let Some(mention) = popup.selected_mention() {
selected_mention = Some((mention.insert_text.clone(), mention.path.clone()));
}
self.active_popup = ActivePopup::None;
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some((insert_text, path)) = selected_mention {
if let Some(path) = path.as_deref() {
self.record_mention_path(&insert_text, path);
}
self.insert_selected_mention(&insert_text);
}
self.active_popup = ActivePopup::None;
}
result
}
fn is_image_path(path: &str) -> bool {
@@ -1511,14 +1548,23 @@ impl ChatComposer {
(rebuilt, rebuilt_elements)
}
fn skills_enabled(&self) -> bool {
self.skills.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.skills.as_ref()
}
fn mentions_enabled(&self) -> bool {
let skills_ready = self
.skills
.as_ref()
.is_some_and(|skills| !skills.is_empty());
let connectors_ready = self.connectors_enabled
&& self
.connectors_snapshot
.as_ref()
.is_some_and(|snapshot| !snapshot.connectors.is_empty());
skills_ready || connectors_ready
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
@@ -1632,8 +1678,8 @@ impl ChatComposer {
Self::current_prefixed_token(textarea, '@', false)
}
fn current_skill_token(&self) -> Option<String> {
if !self.skills_enabled() {
fn current_mention_token(&self) -> Option<String> {
if !self.mentions_enabled() {
return None;
}
Self::current_prefixed_token(&self.textarea, '$', true)
@@ -1691,7 +1737,7 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn insert_selected_skill(&mut self, skill_name: &str) {
fn insert_selected_mention(&mut self, insert_text: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
@@ -1712,7 +1758,7 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
let inserted = format!("${skill_name}");
let inserted = insert_text.to_string();
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
@@ -1721,12 +1767,35 @@ impl ChatComposer {
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
// Skill insertion rebuilds plain text, so drop existing elements.
// Mention insertion rebuilds plain text, so drop existing elements.
self.textarea.set_text_clearing_elements(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
fn record_mention_path(&mut self, insert_text: &str, path: &str) {
let Some(name) = Self::mention_name_from_insert_text(insert_text) else {
return;
};
self.mention_paths.insert(name, path.to_string());
}
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
let name = insert_text.strip_prefix('$')?;
if name.is_empty() {
return None;
}
if name
.as_bytes()
.iter()
.all(|byte| is_mention_name_char(*byte))
{
Some(name.to_string())
} else {
None
}
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
@@ -1765,6 +1834,7 @@ impl ChatComposer {
let is_builtin = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@@ -1951,6 +2021,7 @@ impl ChatComposer {
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@@ -1979,6 +2050,7 @@ impl ChatComposer {
&& let Some(cmd) = slash_commands::find_builtin_command(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
)
@@ -2388,10 +2460,10 @@ impl ChatComposer {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let mention_token = self.current_mention_token();
let allow_command_popup =
self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none();
self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none();
self.sync_command_popup(allow_command_popup);
if matches!(self.active_popup, ActivePopup::Command(_)) {
@@ -2401,20 +2473,20 @@ impl ChatComposer {
self.current_file_query = None;
}
self.dismissed_file_popup_token = None;
self.dismissed_skill_popup_token = None;
self.dismissed_mention_popup_token = None;
return;
}
if let Some(token) = skill_token {
if let Some(token) = mention_token {
if self.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
}
self.sync_skill_popup(token);
self.sync_mention_popup(token);
return;
}
self.dismissed_skill_popup_token = None;
self.dismissed_mention_popup_token = None;
if let Some(token) = file_token {
self.sync_file_search_popup(token);
@@ -2477,6 +2549,7 @@ impl ChatComposer {
if slash_commands::has_builtin_prefix(
name,
self.collaboration_modes_enabled,
self.connectors_enabled,
self.personality_command_enabled,
self.windows_degraded_sandbox_active,
) {
@@ -2529,11 +2602,13 @@ impl ChatComposer {
_ => {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
@@ -2594,32 +2669,99 @@ impl ChatComposer {
self.dismissed_file_popup_token = None;
}
fn sync_skill_popup(&mut self, query: String) {
if self.dismissed_skill_popup_token.as_ref() == Some(&query) {
fn sync_mention_popup(&mut self, query: String) {
if self.dismissed_mention_popup_token.as_ref() == Some(&query) {
return;
}
let skills = match self.skills.as_ref() {
Some(skills) if !skills.is_empty() => skills.clone(),
_ => {
self.active_popup = ActivePopup::None;
return;
}
};
let mentions = self.mention_items();
if mentions.is_empty() {
self.active_popup = ActivePopup::None;
return;
}
match &mut self.active_popup {
ActivePopup::Skill(popup) => {
popup.set_query(&query);
popup.set_skills(skills);
popup.set_mentions(mentions);
}
_ => {
let mut popup = SkillPopup::new(skills);
let mut popup = SkillPopup::new(mentions);
popup.set_query(&query);
self.active_popup = ActivePopup::Skill(popup);
}
}
}
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
let display_name = skill_display_name(skill).to_string();
let description = skill_description(skill);
let skill_name = skill.name.clone();
let search_terms = if display_name == skill.name {
vec![skill_name.clone()]
} else {
vec![skill_name.clone(), display_name.clone()]
};
mentions.push(MentionItem {
display_name,
description,
insert_text: format!("${skill_name}"),
search_terms,
path: Some(skill.path.to_string_lossy().into_owned()),
});
}
}
if self.connectors_enabled
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
{
for connector in &snapshot.connectors {
if !connector.is_accessible {
continue;
}
let display_name = connectors::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_core::connectors::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
});
}
}
mentions
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} - {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
@@ -2658,6 +2800,29 @@ impl ChatComposer {
}
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
fn skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description);
let trimmed = description.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
@@ -2938,6 +3103,7 @@ mod tests {
use tempfile::tempdir;
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
@@ -32,6 +32,7 @@ pub(crate) struct CommandPopup {
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@@ -41,6 +42,7 @@ impl CommandPopup {
// Keep built-in availability in sync with the composer.
let builtins = slash_commands::builtins_for_input(
flags.collaboration_modes_enabled,
flags.connectors_enabled,
flags.personality_command_enabled,
flags.windows_degraded_sandbox_active,
);
@@ -478,6 +480,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
windows_degraded_sandbox_active: false,
},
@@ -496,6 +499,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: false,
windows_degraded_sandbox_active: false,
},
@@ -522,6 +526,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
personality_command_enabled: true,
windows_degraded_sandbox_active: false,
},
+17
View File
@@ -13,8 +13,10 @@
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
@@ -36,8 +38,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::time::Duration;
mod app_link_view;
mod approval_overlay;
mod request_user_input;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use request_user_input::RequestUserInputOverlay;
@@ -204,6 +208,15 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_connectors_snapshot(&mut self, snapshot: Option<ConnectorsSnapshot>) {
self.composer.set_connector_mentions(snapshot);
self.request_redraw();
}
pub fn take_mention_paths(&mut self) -> HashMap<String, String> {
self.composer.take_mention_paths()
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.composer.set_steer_enabled(enabled);
}
@@ -213,6 +226,10 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_connectors_enabled(&mut self, enabled: bool) {
self.composer.set_connectors_enabled(enabled);
}
#[cfg(target_os = "windows")]
pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) {
self.composer.set_windows_degraded_sandbox_active(enabled);
+55 -25
View File
@@ -14,30 +14,35 @@ use super::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt;
use codex_core::skills::model::SkillMetadata;
use crate::text_formatting::truncate_text;
use codex_common::fuzzy_match::fuzzy_match;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use crate::skills_helpers::truncated_skill_display_name;
#[derive(Clone, Debug)]
pub(crate) struct MentionItem {
pub(crate) display_name: String,
pub(crate) description: Option<String>,
pub(crate) insert_text: String,
pub(crate) search_terms: Vec<String>,
pub(crate) path: Option<String>,
}
pub(crate) struct SkillPopup {
query: String,
skills: Vec<SkillMetadata>,
mentions: Vec<MentionItem>,
state: ScrollState,
}
impl SkillPopup {
pub(crate) fn new(skills: Vec<SkillMetadata>) -> Self {
pub(crate) fn new(mentions: Vec<MentionItem>) -> Self {
Self {
query: String::new(),
skills,
mentions,
state: ScrollState::new(),
}
}
pub(crate) fn set_skills(&mut self, skills: Vec<SkillMetadata>) {
self.skills = skills;
pub(crate) fn set_mentions(&mut self, mentions: Vec<MentionItem>) {
self.mentions = mentions;
self.clamp_selection();
}
@@ -64,11 +69,11 @@ impl SkillPopup {
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> {
pub(crate) fn selected_mention(&self) -> Option<&MentionItem> {
let matches = self.filtered_items();
let idx = self.state.selected_idx?;
let skill_idx = matches.get(idx)?;
self.skills.get(*skill_idx)
let mention_idx = matches.get(idx)?;
self.mentions.get(*mention_idx)
}
fn clamp_selection(&mut self) {
@@ -88,14 +93,14 @@ impl SkillPopup {
matches
.into_iter()
.map(|(idx, indices, _score)| {
let skill = &self.skills[idx];
let name = truncated_skill_display_name(skill);
let description = skill_description(skill).to_string();
let mention = &self.mentions[idx];
let name = truncate_text(&mention.display_name, 21);
let description = mention.description.clone().unwrap_or_default();
GenericDisplayRow {
name,
match_indices: indices,
display_shortcut: None,
description: Some(description),
description: Some(description).filter(|desc| !desc.is_empty()),
is_disabled: false,
disabled_reason: None,
wrap_indent: None,
@@ -109,23 +114,48 @@ impl SkillPopup {
let mut out: Vec<(usize, Option<Vec<usize>>, i32)> = Vec::new();
if filter.is_empty() {
for (idx, _skill) in self.skills.iter().enumerate() {
for (idx, _mention) in self.mentions.iter().enumerate() {
out.push((idx, None, 0));
}
return out;
}
for (idx, skill) in self.skills.iter().enumerate() {
let display_name = skill_display_name(skill);
if let Some((indices, score)) = match_skill(filter, display_name, &skill.name) {
for (idx, mention) in self.mentions.iter().enumerate() {
let mut best_match: Option<(Option<Vec<usize>>, i32)> = None;
if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) {
best_match = Some((Some(indices), score));
}
for term in &mention.search_terms {
if term == &mention.display_name {
continue;
}
if let Some((_indices, score)) = fuzzy_match(term, filter) {
match best_match.as_mut() {
Some((best_indices, best_score)) => {
if score > *best_score {
*best_score = score;
*best_indices = None;
}
}
None => {
best_match = Some((None, score));
}
}
}
}
if let Some((indices, score)) = best_match {
out.push((idx, indices, score));
}
}
out.sort_by(|a, b| {
a.2.cmp(&b.2).then_with(|| {
let an = skill_display_name(&self.skills[a.0]);
let bn = skill_display_name(&self.skills[b.0]);
let an = self.mentions[a.0].display_name.as_str();
let bn = self.mentions[b.0].display_name.as_str();
an.cmp(bn)
})
});
@@ -154,7 +184,7 @@ impl WidgetRef for SkillPopup {
&rows,
&self.state,
MAX_POPUP_ROWS,
"no skills",
"no matches",
);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
@@ -172,7 +202,7 @@ fn skill_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to select or ".into(),
" to insert or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
@@ -11,6 +11,7 @@ use crate::slash_command::built_in_slash_commands;
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Vec<(&'static str, SlashCommand)> {
@@ -18,6 +19,7 @@ pub(crate) fn builtins_for_input(
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.collect()
}
@@ -26,11 +28,13 @@ pub(crate) fn builtins_for_input(
pub(crate) fn find_builtin_command(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> Option<SlashCommand> {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
allow_elevate_sandbox,
)
@@ -43,11 +47,13 @@ pub(crate) fn find_builtin_command(
pub(crate) fn has_builtin_prefix(
name: &str,
collaboration_modes_enabled: bool,
connectors_enabled: bool,
personality_command_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
collaboration_modes_enabled,
connectors_enabled,
personality_command_enabled,
allow_elevate_sandbox,
)
+276 -2
View File
@@ -31,6 +31,7 @@ use std::time::Instant;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AuthMode;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_core::config::ConstraintResult;
use codex_core::config::types::Notifications;
@@ -131,6 +132,7 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
@@ -189,7 +191,9 @@ pub(crate) use self::agent::spawn_op_forwarder;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
use self::skills::find_skill_mentions;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
use crate::streaming::controller::StreamController;
use std::path::Path;
@@ -386,6 +390,15 @@ enum RateLimitSwitchPromptState {
Shown,
}
#[derive(Debug, Clone, Default)]
enum ConnectorsCacheState {
#[default]
Uninitialized,
Loading,
Ready(ConnectorsSnapshot),
Failed(String),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ExternalEditorState {
#[default]
@@ -460,6 +473,7 @@ pub(crate) struct ChatWidget {
/// bottom pane is treated as "running" while this is populated, even if no agent turn is
/// currently executing.
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
connectors_cache: ConnectorsCacheState,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
@@ -549,6 +563,7 @@ pub(crate) struct UserMessage {
text: String,
local_images: Vec<LocalImageAttachment>,
text_elements: Vec<TextElement>,
mention_paths: HashMap<String, String>,
}
impl From<String> for UserMessage {
@@ -558,6 +573,7 @@ impl From<String> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
}
}
}
@@ -569,6 +585,7 @@ impl From<&str> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
}
}
}
@@ -594,6 +611,7 @@ pub(crate) fn create_initial_user_message(
text,
local_images,
text_elements,
mention_paths: HashMap::new(),
})
}
}
@@ -607,12 +625,14 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text,
text_elements,
local_images,
mention_paths,
} = message;
if local_images.is_empty() {
return UserMessage {
text,
text_elements,
local_images,
mention_paths,
};
}
@@ -667,6 +687,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text: rebuilt,
local_images: remapped_images,
text_elements: rebuilt_elements,
mention_paths,
}
}
@@ -732,6 +753,7 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.set_skills(None);
self.bottom_pane.set_connectors_snapshot(None);
self.thread_id = Some(event.session_id);
self.forked_from = event.forked_from_id;
self.current_rollout_path = event.rollout_path.clone();
@@ -762,6 +784,9 @@ impl ChatWidget {
cwds: Vec::new(),
force_reload: true,
});
if self.connectors_enabled() {
self.prefetch_connectors();
}
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -797,6 +822,25 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn open_app_link_view(
&mut self,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) {
let view = crate::bottom_pane::AppLinkView::new(
title,
description,
instructions,
url,
is_installed,
);
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
let params = crate::bottom_pane::feedback_upload_consent_params(
self.app_event_tx.clone(),
@@ -1240,6 +1284,7 @@ impl ChatWidget {
text: self.bottom_pane.composer_text(),
text_elements: self.bottom_pane.composer_text_elements(),
local_images: self.bottom_pane.composer_local_images(),
mention_paths: HashMap::new(),
};
let mut to_merge: Vec<UserMessage> = self.queued_user_messages.drain(..).collect();
@@ -1251,6 +1296,7 @@ impl ChatWidget {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
mention_paths: HashMap::new(),
};
let mut combined_offset = 0usize;
let mut next_image_label = 1usize;
@@ -1272,6 +1318,7 @@ impl ChatWidget {
elem
}));
combined.local_images.extend(message.local_images);
combined.mention_paths.extend(message.mention_paths);
}
Some(combined)
@@ -2028,6 +2075,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -2071,6 +2119,10 @@ impl ChatWidget {
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.config.features.enabled(Feature::Apps));
widget
}
@@ -2161,6 +2213,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -2287,6 +2340,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
@@ -2436,6 +2490,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
if self.is_session_configured() {
// Submitted is only emitted when steer is enabled (Enter sends immediately).
@@ -2458,6 +2513,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
};
self.queue_user_message(user_message);
}
@@ -2675,6 +2731,9 @@ impl ChatWidget {
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
@@ -2833,6 +2892,7 @@ impl ChatWidget {
text,
local_images,
text_elements,
mention_paths,
} = user_message;
if text.is_empty() && local_images.is_empty() {
return;
@@ -2871,8 +2931,15 @@ impl ChatWidget {
});
}
let mentions = collect_tool_mentions(&text, &mention_paths);
let mut skill_names_lower: HashSet<String> = HashSet::new();
if let Some(skills) = self.bottom_pane.skills() {
let skill_mentions = find_skill_mentions(&text, skills);
skill_names_lower = skills
.iter()
.map(|skill| skill.name.to_ascii_lowercase())
.collect();
let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills);
for skill in skill_mentions {
items.push(UserInput::Skill {
name: skill.name.clone(),
@@ -2881,6 +2948,17 @@ impl ChatWidget {
}
}
if let Some(apps) = self.connectors_for_mentions() {
let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower);
for app in app_mentions {
let app_id = app.id.as_str();
items.push(UserInput::Mention {
name: app.name.clone(),
path: format!("app://{app_id}"),
});
}
}
let effective_mode = self.effective_collaboration_mode();
let collaboration_mode = if self.collaboration_modes_enabled() {
self.active_collaboration_mask
@@ -3289,6 +3367,28 @@ impl ChatWidget {
}
}
fn prefetch_connectors(&mut self) {
if !self.connectors_enabled() {
return;
}
if matches!(self.connectors_cache, ConnectorsCacheState::Loading) {
return;
}
self.connectors_cache = ConnectorsCacheState::Loading;
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result: Result<ConnectorsSnapshot, anyhow::Error> = async {
let connectors = connectors::list_connectors(&config).await?;
Ok(ConnectorsSnapshot { connectors })
}
.await;
let result = result.map_err(|err| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded(result));
});
}
fn prefetch_rate_limits(&mut self) {
self.stop_rate_limit_poller();
@@ -4973,6 +5073,21 @@ impl ChatWidget {
self.request_redraw();
}
fn connectors_enabled(&self) -> bool {
self.config.features.enabled(Feature::Apps)
}
fn connectors_for_mentions(&self) -> Option<&[connectors::AppInfo]> {
if !self.connectors_enabled() {
return None;
}
match &self.connectors_cache {
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
_ => None,
}
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
@@ -5036,6 +5151,152 @@ impl ChatWidget {
}
}
pub(crate) fn add_connectors_output(&mut self) {
if !self.connectors_enabled() {
self.add_info_message(
"Apps are disabled.".to_string(),
Some("Enable the apps feature to use $ or /apps.".to_string()),
);
return;
}
match self.connectors_cache.clone() {
ConnectorsCacheState::Ready(snapshot) => {
if snapshot.connectors.is_empty() {
self.add_info_message("No apps available.".to_string(), None);
} else {
self.open_connectors_popup(&snapshot.connectors);
}
}
ConnectorsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
// Retry on demand so `/apps` can recover after transient failures.
self.prefetch_connectors();
}
ConnectorsCacheState::Loading => {
self.add_to_history(history_cell::new_info_event(
"Apps are still loading.".to_string(),
Some("Try again in a moment.".to_string()),
));
}
ConnectorsCacheState::Uninitialized => {
self.prefetch_connectors();
self.add_to_history(history_cell::new_info_event(
"Apps are still loading.".to_string(),
Some("Try again in a moment.".to_string()),
));
}
}
self.request_redraw();
}
fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) {
let total = connectors.len();
let installed = connectors
.iter()
.filter(|connector| connector.is_accessible)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from(
"Use $ to insert an installed app into your prompt.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available apps.").dim(),
));
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
for connector in connectors {
let connector_label = connectors::connector_display_label(connector);
let connector_title = connector_label.clone();
let link_description = Self::connector_description(connector);
let description = Self::connector_brief_description(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
description: Some(description),
search_value: Some(search_value),
..Default::default()
};
let is_installed = connector.is_accessible;
let (selected_label, missing_label, instructions) = if connector.is_accessible {
(
"Press Enter to view the app link.",
"App link unavailable.",
"Manage this app in your browser.",
)
} else {
(
"Press Enter to view the install link.",
"Install link unavailable.",
"Install this app in your browser, then reload Codex.",
)
};
if let Some(install_url) = connector.install_url.clone() {
let title = connector_title.clone();
let instructions = instructions.to_string();
let description = link_description.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
title: title.clone(),
description: description.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
is_installed,
});
})];
item.dismiss_on_select = true;
item.selected_description = Some(selected_label.to_string());
} else {
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(missing_label.to_string(), None),
)));
})];
item.dismiss_on_select = true;
item.selected_description = Some(missing_label.to_string());
}
items.push(item);
}
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(Self::connectors_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
..Default::default()
});
}
fn connectors_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close.".into(),
])
}
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
/// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
@@ -5220,6 +5481,19 @@ impl ChatWidget {
self.set_skills_from_response(&ev);
}
pub(crate) fn on_connectors_loaded(&mut self, result: Result<ConnectorsSnapshot, String>) {
self.connectors_cache = match result {
Ok(connectors) => ConnectorsCacheState::Ready(connectors),
Err(err) => ConnectorsCacheState::Failed(err),
};
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache {
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else {
self.bottom_pane.set_connectors_snapshot(None);
}
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
+252 -16
View File
@@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_core::protocol::SkillsListEntry;
@@ -192,22 +194,256 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
}
}
pub(crate) fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
let mut seen: HashSet<String> = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen.contains(&skill.name) {
continue;
}
let needle = format!("${}", skill.name);
if text.contains(&needle) {
seen.insert(skill.name.clone());
matches.push(skill.clone());
}
}
matches
}
fn normalize_skill_config_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn collect_tool_mentions(
text: &str,
mention_paths: &HashMap<String, String>,
) -> ToolMentions {
let mut mentions = extract_tool_mentions_from_text(text);
for (name, path) in mention_paths {
if mentions.names.contains(name) {
mentions.linked_paths.insert(name.clone(), path.clone());
}
}
mentions
}
pub(crate) fn find_skill_mentions_with_tool_mentions(
mentions: &ToolMentions,
skills: &[SkillMetadata],
) -> Vec<SkillMetadata> {
let mention_skill_paths: HashSet<&str> = mentions
.linked_paths
.values()
.filter(|path| is_skill_path(path))
.map(|path| normalize_skill_path(path))
.collect();
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
let path_str = skill.path.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
matches.push(skill.clone());
}
}
for skill in skills {
if seen_paths.contains(&skill.path) {
continue;
}
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path.clone());
matches.push(skill.clone());
}
}
matches
}
pub(crate) fn find_app_mentions(
mentions: &ToolMentions,
apps: &[AppInfo],
skill_names_lower: &HashSet<String>,
) -> Vec<AppInfo> {
let mut explicit_names = HashSet::new();
let mut selected_ids = HashSet::new();
for (name, path) in &mentions.linked_paths {
if let Some(connector_id) = app_id_from_path(path) {
explicit_names.insert(name.clone());
selected_ids.insert(connector_id.to_string());
}
}
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for app in apps {
let slug = connector_mention_slug(app);
*slug_counts.entry(slug).or_insert(0) += 1;
}
for app in apps {
let slug = connector_mention_slug(app);
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
if mentions.names.contains(&slug)
&& !explicit_names.contains(&slug)
&& slug_count == 1
&& !skill_names_lower.contains(&slug)
{
selected_ids.insert(app.id.clone());
}
}
apps.iter()
.filter(|app| selected_ids.contains(&app.id))
.cloned()
.collect()
}
pub(crate) struct ToolMentions {
names: HashSet<String>,
linked_paths: HashMap<String, String>,
}
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
let text_bytes = text.as_bytes();
let mut names: HashSet<String> = HashSet::new();
let mut linked_paths: HashMap<String, String> = HashMap::new();
let mut index = 0;
while index < text_bytes.len() {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, index)
{
if !is_common_env_var(name) {
if !is_app_or_mcp_path(path) {
names.insert(name.to_string());
}
linked_paths
.entry(name.to_string())
.or_insert(path.to_string());
}
index = end_index;
continue;
}
if byte != b'$' {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first_name_byte) = text_bytes.get(name_start) else {
index += 1;
continue;
};
if !is_mention_name_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
names.insert(name.to_string());
}
index = name_end;
}
ToolMentions {
names,
linked_paths,
}
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
) -> Option<(&'a str, &'a str, usize)> {
let dollar_index = start + 1;
if text_bytes.get(dollar_index) != Some(&b'$') {
return None;
}
let name_start = dollar_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_mention_name_char(*first_name_byte) {
return None;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
if text_bytes.get(name_end) != Some(&b']') {
return None;
}
let mut path_start = name_end + 1;
while let Some(next_byte) = text_bytes.get(path_start)
&& next_byte.is_ascii_whitespace()
{
path_start += 1;
}
if text_bytes.get(path_start) != Some(&b'(') {
return None;
}
let mut path_end = path_start + 1;
while let Some(next_byte) = text_bytes.get(path_end)
&& *next_byte != b')'
{
path_end += 1;
}
if text_bytes.get(path_end) != Some(&b')') {
return None;
}
let path = text[path_start + 1..path_end].trim();
if path.is_empty() {
return None;
}
let name = &text[name_start..name_end];
Some((name, path, path_end + 1))
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"PATH"
| "HOME"
| "USER"
| "SHELL"
| "PWD"
| "TMPDIR"
| "TEMP"
| "TMP"
| "LANG"
| "TERM"
| "XDG_CONFIG_HOME"
)
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn is_skill_path(path: &str) -> bool {
!is_app_or_mcp_path(path)
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix("skill://").unwrap_or(path)
}
fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix("app://")
.filter(|value| !value.is_empty())
}
fn is_app_or_mcp_path(path: &str) -> bool {
path.starts_with("app://") || path.starts_with("mcp://")
}
+5
View File
@@ -355,6 +355,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
path: first_images[0].clone(),
}],
text_elements: first_elements,
mention_paths: HashMap::new(),
});
chat.queued_user_messages.push_back(UserMessage {
text: second_text,
@@ -363,6 +364,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
path: second_images[0].clone(),
}],
text_elements: second_elements,
mention_paths: HashMap::new(),
});
chat.refresh_queued_user_messages();
@@ -442,6 +444,7 @@ async fn remap_placeholders_uses_attachment_labels() {
text,
text_elements: elements,
local_images: attachments,
mention_paths: HashMap::new(),
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);
@@ -502,6 +505,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
text,
text_elements: elements,
local_images: attachments,
mention_paths: HashMap::new(),
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);
@@ -810,6 +814,7 @@ async fn make_chatwidget_manual(
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
connectors_cache: ConnectorsCacheState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
-4
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,
+3
View File
@@ -33,6 +33,7 @@ pub enum SlashCommand {
Mention,
Status,
Mcp,
Apps,
Logout,
Quit,
Exit,
@@ -69,6 +70,7 @@ impl SlashCommand {
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Apps => "manage apps",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
SlashCommand::TestApproval => "test approval request",
@@ -104,6 +106,7 @@ impl SlashCommand {
| SlashCommand::Status
| SlashCommand::Ps
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Feedback
| SlashCommand::Quit
| SlashCommand::Exit => true,