[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 2028 additions and 365 deletions

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://")
}