diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index c3513bb06..1bb08a75b 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -97,6 +97,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c4fba97f6..ccf0fac5d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,6 +109,7 @@ use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use super::skill_popup::SkillPopup; +use super::slash_commands; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -120,7 +121,6 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; @@ -148,13 +148,6 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -197,6 +190,30 @@ enum PromptSelectionAction { }, } +/// Feature flags for reusing the chat composer in other bottom-pane surfaces. +/// +/// The default keeps today's behavior intact. Other call sites can opt out of +/// specific behaviors by constructing a config with those flags set to `false`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChatComposerConfig { + /// Whether command/file/skill popups are allowed to appear. + pub(crate) popups_enabled: bool, + /// Whether `/...` input is parsed and dispatched as slash commands. + pub(crate) slash_commands_enabled: bool, + /// Whether pasting a file path can attach local images. + pub(crate) image_paste_enabled: bool, +} + +impl Default for ChatComposerConfig { + fn default() -> Self { + Self { + popups_enabled: true, + slash_commands_enabled: true, + image_paste_enabled: true, + } + } +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -234,6 +251,7 @@ pub(crate) struct ChatComposer { /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, + config: ChatComposerConfig, collaboration_mode_indicator: Option, personality_command_enabled: bool, } @@ -261,6 +279,28 @@ impl ChatComposer { enhanced_keys_supported: bool, placeholder_text: String, disable_paste_burst: bool, + ) -> Self { + Self::new_with_config( + has_input_focus, + app_event_tx, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ChatComposerConfig::default(), + ) + } + + /// Construct a composer with explicit feature gating. + /// + /// This enables reuse in contexts like request-user-input where we want + /// the same visuals and editing behavior without slash commands or popups. + pub(crate) fn new_with_config( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + config: ChatComposerConfig, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; @@ -296,6 +336,7 @@ impl ChatComposer { dismissed_skill_popup_token: None, steer_enabled: false, collaboration_modes_enabled: false, + config, collaboration_mode_indicator: None, personality_command_enabled: false, }; @@ -333,6 +374,18 @@ impl ChatComposer { self.personality_command_enabled = enabled; } + /// Centralized feature gating keeps config checks out of call sites. + fn popups_enabled(&self) -> bool { + self.config.popups_enabled + } + + fn slash_commands_enabled(&self) -> bool { + self.config.slash_commands_enabled + } + + fn image_paste_enabled(&self) -> bool { + self.config.image_paste_enabled + } fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -417,7 +470,10 @@ impl ChatComposer { let placeholder = self.next_large_paste_placeholder(char_count); self.textarea.insert_element(&placeholder); self.pending_pastes.push((placeholder, pasted)); - } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { + } else if char_count > 1 + && self.image_paste_enabled() + && self.handle_paste_image_path(pasted.clone()) + { self.textarea.insert_str(" "); } else { self.textarea.insert_str(&pasted); @@ -1634,14 +1690,17 @@ impl ChatComposer { text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { + if self.slash_commands_enabled() + && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) + { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = Self::built_in_slash_commands_for_input( + let is_builtin = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .any(|(command_name, _)| command_name == name); + .is_some(); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1670,26 +1729,28 @@ impl ChatComposer { } } - let expanded_prompt = - match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded.text; - text_elements = expanded.text_elements; + if self.slash_commands_enabled() { + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded.text; + text_elements = expanded.text_elements; + } } // Custom prompt expansion can remove or rewrite image placeholders, so prune any // attachments that no longer have a corresponding placeholder in the expanded text. @@ -1729,14 +1790,15 @@ impl ChatComposer { // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); + let in_slash_context = self.slash_commands_enabled() + && (matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context @@ -1803,14 +1865,17 @@ impl ChatComposer { /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { + if !self.slash_commands_enabled() { + return None; + } let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + && let Some(cmd) = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .find(|(n, _)| *n == name) { self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) @@ -1822,6 +1887,9 @@ impl ChatComposer { /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { + if !self.slash_commands_enabled() { + return None; + } let original_input = self.textarea.text().to_string(); let input_starts_with_space = original_input.starts_with(' '); @@ -1830,11 +1898,11 @@ impl ChatComposer { if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + && let Some(cmd) = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text_clearing_elements(""); @@ -2188,6 +2256,10 @@ impl ChatComposer { } fn sync_popups(&mut self) { + if !self.popups_enabled() { + self.active_popup = ActivePopup::None; + return; + } let file_token = Self::current_at_token(&self.textarea); let browsing_history = self .history @@ -2200,7 +2272,8 @@ impl ChatComposer { } let skill_token = self.current_skill_token(); - let allow_command_popup = file_token.is_none() && skill_token.is_none(); + let allow_command_popup = + self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none(); self.sync_command_popup(allow_command_popup); if matches!(self.active_popup, ActivePopup::Command(_)) { @@ -2261,24 +2334,24 @@ impl ChatComposer { /// prefix for any known command (built-in or custom prompt). /// Empty names only count when there is no extra content after the '/'. fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if !self.slash_commands_enabled() { + return false; + } if name.is_empty() { return rest_after_name.is_empty(); } - let builtin_match = Self::built_in_slash_commands_for_input( + if slash_commands::has_builtin_prefix( + name, self.collaboration_modes_enabled, self.personality_command_enabled, - ) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); - - if builtin_match { + ) { return true; } - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - self.custom_prompts - .iter() - .any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some()) + self.custom_prompts.iter().any(|prompt| { + fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some() + }) } /// Synchronize `self.command_popup` with the current text in the @@ -2336,21 +2409,6 @@ impl ChatComposer { } } } - - fn built_in_slash_commands_for_input( - collaboration_modes_enabled: bool, - personality_command_enabled: bool, - ) -> impl Iterator { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - built_in_slash_commands() - .into_iter() - .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(move |(_, cmd)| { - personality_command_enabled || *cmd != SlashCommand::Personality - }) - } - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d0805707f..3c54dff0f 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -6,21 +6,14 @@ use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; +use super::slash_commands; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -44,15 +37,11 @@ pub(crate) struct CommandPopupFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(|(_, cmd)| { - flags.personality_command_enabled || *cmd != SlashCommand::Personality - }) - .collect(); + // Keep built-in availability in sync with the composer. + let builtins = slash_commands::builtins_for_input( + flags.collaboration_modes_enabled, + flags.personality_command_enabled, + ); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7591b9932..095a09550 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -9,18 +9,15 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::render_menu_surface; use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; -use crate::render::Insets; -use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; -use crate::style::user_message_style; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -476,16 +473,16 @@ impl Renderable for ListSelectionView { let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); - Block::default() - .style(user_message_style()) - .render(content_area, buf); + let outer_content_area = content_area; + // Paint the shared menu surface and then layout inside the returned inset. + let content_area = render_menu_surface(outer_content_area, buf); let header_height = self .header // Subtract 4 for the padding on the left and right of the header. - .desired_height(content_area.width.saturating_sub(4)); + .desired_height(outer_content_area.width.saturating_sub(4)); let rows = self.build_rows(); - let rows_width = Self::rows_width(content_area.width); + let rows_width = Self::rows_width(outer_content_area.width); let rows_height = measure_rows_height( &rows, &self.state, @@ -498,7 +495,7 @@ impl Renderable for ListSelectionView { Constraint::Length(if self.is_searchable { 1 } else { 0 }), Constraint::Length(rows_height), ]) - .areas(content_area.inset(Insets::vh(1, 2))); + .areas(content_area); if header_area.height < header_height { let [header_area, elision_area] = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index aea847d21..a435046cb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -59,6 +59,7 @@ mod list_selection_view; mod prompt_args; mod skill_popup; mod skills_toggle_view; +mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index c9ca076d6..0708bb159 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -7,11 +7,15 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Block; use ratatui::widgets::Widget; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use crate::key_hint::KeyBinding; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; use super::scroll_state::ScrollState; @@ -27,6 +31,31 @@ pub(crate) struct GenericDisplayRow { pub wrap_indent: Option, // optional indent for wrapped lines } +const MENU_SURFACE_INSET_V: u16 = 1; +const MENU_SURFACE_INSET_H: u16 = 2; + +/// Apply the shared "menu surface" padding used by bottom-pane overlays. +/// +/// Rendering code should generally call [`render_menu_surface`] and then lay +/// out content inside the returned inset rect. +pub(crate) fn menu_surface_inset(area: Rect) -> Rect { + area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) +} + +/// Paint the shared menu background and return the inset content area. +/// +/// This keeps the surface treatment consistent across selection-style overlays +/// (for example `/model`, approvals, and request-user-input). +pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { + if area.is_empty() { + return area; + } + Block::default() + .style(user_message_style()) + .render(area, buf); + menu_surface_inset(area) +} + pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs new file mode 100644 index 000000000..55765a00d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -0,0 +1,54 @@ +//! Shared helpers for filtering and matching built-in slash commands. +//! +//! The same sandbox- and feature-gating rules are used by both the composer +//! and the command popup. Centralizing them here keeps those call sites small +//! and ensures they stay in sync. +use codex_common::fuzzy_match::fuzzy_match; + +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; + +/// Whether the Windows degraded-sandbox elevation flow is currently allowed. +pub(crate) fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + +/// Return the built-ins that should be visible/usable for the current input. +pub(crate) fn builtins_for_input( + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> Vec<(&'static str, SlashCommand)> { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) + .collect() +} + +/// Find a single built-in command by exact name, after applying the gating rules. +pub(crate) fn find_builtin_command( + name: &str, + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> Option { + builtins_for_input(collaboration_modes_enabled, personality_command_enabled) + .into_iter() + .find(|(command_name, _)| *command_name == name) + .map(|(_, cmd)| cmd) +} + +/// Whether any visible built-in fuzzily matches the provided prefix. +pub(crate) fn has_builtin_prefix( + name: &str, + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> bool { + builtins_for_input(collaboration_modes_enabled, personality_command_enabled) + .into_iter() + .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) +} diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 6436426cb..baab4ce0e 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -49,6 +49,31 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl - After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the latest text + cursor. +## Config gating for reuse + +`ChatComposer` now supports feature gating via `ChatComposerConfig` +(`codex-rs/tui/src/bottom_pane/chat_composer.rs`). The default config preserves current chat +behavior. + +Flags: + +- `popups_enabled` +- `slash_commands_enabled` +- `image_paste_enabled` + +Key effects when disabled: + +- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`. +- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands. +- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in + `prepare_submission_text`. +- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled. +- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped. + +Built-in slash command availability is centralized in +`codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command +popup so gating stays in sync. + ## Submission flow (Enter/Tab) There are multiple submission paths, but they share the same core rules: