Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Ibrahim
22d0bf0821 prompt 2026-01-26 14:47:21 -08:00
Ahmed Ibrahim
6fb317c792 Add composer config and shared menu surface helpers 2026-01-26 00:43:35 -08:00
7 changed files with 213 additions and 90 deletions

View File

@@ -24,6 +24,7 @@ You SHOULD ask many questions, but each question must:
- materially change the spec/plan, OR
- confirm/lock an assumption, OR
- choose between meaningful tradeoffs.
- not be answerable by non-mutating commands
Batch questions (e.g., 410) per `request_user_input` call to keep momentum.
## Two kinds of unknowns (treat differently)

View File

@@ -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<TextAreaState>,
@@ -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<CollaborationModeIndicator>,
}
@@ -260,6 +278,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;
@@ -295,6 +335,7 @@ impl ChatComposer {
dismissed_skill_popup_token: None,
steer_enabled: false,
collaboration_modes_enabled: false,
config,
collaboration_mode_indicator: None,
};
// Apply configuration via the setter to keep side-effects centralized.
@@ -327,6 +368,19 @@ impl ChatComposer {
self.collaboration_mode_indicator = indicator;
}
/// 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
@@ -411,7 +465,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);
@@ -1628,12 +1685,14 @@ 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(self.collaboration_modes_enabled)
.any(|(command_name, _)| command_name == name);
slash_commands::find_builtin_command(name, self.collaboration_modes_enabled)
.is_some();
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
@@ -1662,26 +1721,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.
@@ -1721,14 +1782,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
@@ -1795,12 +1857,14 @@ 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<InputResult> {
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(self.collaboration_modes_enabled)
.find(|(n, _)| *n == name)
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.collaboration_modes_enabled)
{
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
@@ -1812,6 +1876,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<InputResult> {
if !self.slash_commands_enabled() {
return None;
}
let original_input = self.textarea.text().to_string();
let input_starts_with_space = original_input.starts_with(' ');
@@ -1820,9 +1887,8 @@ impl ChatComposer {
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) =
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
.find(|(command_name, _)| *command_name == name)
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.collaboration_modes_enabled)
&& cmd == SlashCommand::Review
{
self.textarea.set_text_clearing_elements("");
@@ -2176,6 +2242,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
@@ -2188,7 +2258,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(_)) {
@@ -2249,22 +2320,20 @@ 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(self.collaboration_modes_enabled)
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
if slash_commands::has_builtin_prefix(name, self.collaboration_modes_enabled) {
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
@@ -2321,16 +2390,6 @@ impl ChatComposer {
}
}
fn built_in_slash_commands_for_input(
collaboration_modes_enabled: bool,
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.custom_prompts = prompts.clone();
if let ActivePopup::Command(popup) = &mut self.active_popup {

View File

@@ -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 {
@@ -43,12 +36,8 @@ pub(crate) struct CommandPopupFlags {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, 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)
.collect();
// Keep built-in availability in sync with the composer.
let builtins = slash_commands::builtins_for_input(flags.collaboration_modes_enabled);
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));

View File

@@ -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;
@@ -465,16 +462,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,
@@ -487,7 +484,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] =

View File

@@ -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;

View File

@@ -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;
@@ -26,6 +30,31 @@ pub(crate) struct GenericDisplayRow {
pub wrap_indent: Option<usize>, // 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<Line<'a>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;

View File

@@ -0,0 +1,47 @@
//! 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,
) -> 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)
.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,
) -> Option<SlashCommand> {
builtins_for_input(collaboration_modes_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) -> bool {
builtins_for_input(collaboration_modes_enabled)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
}