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