Codex Handbook
tui/src/bottom_pane/chat_composer/slash_input.rs 650 lines
//! Slash-command input parsing, cursor detection, and completion helpers.use std::ops::Range;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyModifiers;use crate::bottom_pane::command_popup::CommandItem;use crate::bottom_pane::command_popup::CommandPopup;use crate::bottom_pane::command_popup::CommandPopupFlags;use crate::bottom_pane::prompt_args::parse_slash_name;use crate::bottom_pane::slash_commands::BuiltinCommandFlags;use crate::bottom_pane::slash_commands::ServiceTierCommand;use crate::bottom_pane::slash_commands::SlashCommandItem;use crate::bottom_pane::slash_commands::find_slash_command;use crate::bottom_pane::slash_commands::has_slash_command_prefix;use crate::slash_command::SlashCommand;use codex_protocol::user_input::ByteRange;use codex_protocol::user_input::TextElement;use super::super::footer::esc_hint_mode;use super::super::footer::reset_mode_after_activity;use super::ActivePopup;use super::ChatComposer;use super::InputResult;use super::QueuedInputAction;#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(super) enum SlashValidation {    Immediate,    Deferred,}pub(super) enum SubmissionValidation {    Valid,    UnknownCommand(String),}pub(super) struct InlineCommand<'a> {    pub(super) command: SlashCommandItem,    pub(super) rest: &'a str,    pub(super) rest_offset: usize,}pub(super) struct SlashInput<'a> {    enabled: bool,    is_bash_mode: bool,    command_flags: BuiltinCommandFlags,    service_tier_commands: &'a [ServiceTierCommand],}impl<'a> SlashInput<'a> {    pub(super) fn new(        enabled: bool,        is_bash_mode: bool,        command_flags: BuiltinCommandFlags,        service_tier_commands: &'a [ServiceTierCommand],    ) -> Self {        Self {            enabled,            is_bash_mode,            command_flags,            service_tier_commands,        }    }    pub(super) fn validate_submission(        &self,        text: &str,        input_starts_with_space: bool,    ) -> SubmissionValidation {        if !self.enabled {            return SubmissionValidation::Valid;        }        let Some((name, _rest, _rest_offset)) = parse_slash_name(text) else {            return SubmissionValidation::Valid;        };        if input_starts_with_space || name.contains('/') {            return SubmissionValidation::Valid;        }        if self.command(name).is_some() {            SubmissionValidation::Valid        } else {            SubmissionValidation::UnknownCommand(name.to_string())        }    }    pub(super) fn bare_command(&self, text: &str) -> Option<SlashCommandItem> {        if !self.enabled || self.is_bash_mode {            return None;        }        let first_line = text.lines().next().unwrap_or("");        let (name, rest, _rest_offset) = parse_slash_name(first_line)?;        if !rest.is_empty() {            return None;        }        let command = self.command(name)?;        if command.supports_inline_args()            && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty())        {            return None;        }        Some(command)    }    pub(super) fn inline_command<'text>(&self, text: &'text str) -> Option<InlineCommand<'text>> {        if !self.enabled || self.is_bash_mode || text.starts_with(' ') {            return None;        }        let (name, rest, rest_offset) = parse_slash_name(text)?;        if rest.is_empty() || name.contains('/') {            return None;        }        let command = self.command(name)?;        command.supports_inline_args().then_some(InlineCommand {            command,            rest,            rest_offset,        })    }    pub(super) fn should_parse_on_dequeue(&self, text: &str) -> bool {        self.enabled && !text.starts_with(' ') && text.trim().starts_with('/')    }    pub(super) fn command_element_range(        &self,        first_line: &str,        cursor: usize,    ) -> Option<Range<usize>> {        if self.is_bash_mode {            return None;        }        let (name, _rest, _rest_offset) = parse_slash_name(first_line)?;        if name.contains('/') {            return None;        }        let element_end = 1 + name.len();        // A draft tail can make an in-progress prefix look complete ("/re" + "view").        // Keep it editable until the cursor leaves the command name.        if cursor <= first_line.len() && (1..element_end).contains(&cursor) {            return None;        }        let has_space_after = first_line            .get(element_end..)            .and_then(|tail| tail.chars().next())            .is_some_and(char::is_whitespace);        if !has_space_after {            return None;        }        self.command(name).is_some().then_some(0..element_end)    }    pub(super) fn is_editing_command_name(&self, first_line: &str, cursor: usize) -> bool {        let Some((name, rest)) = command_under_cursor(first_line, cursor) else {            return false;        };        if !self.enabled {            return false;        }        if name.is_empty() {            return rest.is_empty();        }        has_slash_command_prefix(name, self.command_flags, self.service_tier_commands)    }    pub(super) fn command_popup(&self, filter_text: &str) -> CommandPopup {        let mut command_popup = CommandPopup::new(            CommandPopupFlags {                collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled,                connectors_enabled: self.command_flags.connectors_enabled,                plugins_command_enabled: self.command_flags.plugins_command_enabled,                token_activity_command_enabled: self.command_flags.token_activity_command_enabled,                service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled,                goal_command_enabled: self.command_flags.goal_command_enabled,                personality_command_enabled: self.command_flags.personality_command_enabled,                windows_degraded_sandbox_active: self.command_flags.allow_elevate_sandbox,                side_conversation_active: self.command_flags.side_conversation_active,            },            self.service_tier_commands.to_vec(),        );        command_popup.on_composer_text_change(filter_text.to_string());        command_popup    }    fn command(&self, name: &str) -> Option<SlashCommandItem> {        find_slash_command(name, self.command_flags, self.service_tier_commands)    }}pub(super) fn queued_input_action(    prepared_text: &str,    defer_slash_validation: bool,) -> QueuedInputAction {    if defer_slash_validation && prepared_text.starts_with('/') {        QueuedInputAction::ParseSlash    } else if prepared_text.starts_with('!') {        QueuedInputAction::RunShell    } else {        QueuedInputAction::Plain    }}impl ChatComposer {    /// Handle key event when the slash-command popup is visible.    pub(super) fn handle_key_event_with_slash_popup(        &mut self,        key_event: KeyEvent,    ) -> (InputResult, bool) {        if self.handle_shortcut_overlay_key(&key_event) {            return (InputResult::None, true);        }        if key_event.code == KeyCode::Esc {            let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running);            if next_mode != self.footer.mode {                self.footer.mode = next_mode;                return (InputResult::None, true);            }        } else {            self.footer.mode = reset_mode_after_activity(self.footer.mode);        }        let ActivePopup::Command(popup) = &mut self.popups.active else {            unreachable!();        };        match key_event {            KeyEvent {                code: KeyCode::Up, ..            }            | KeyEvent {                code: KeyCode::Char('p'),                modifiers: KeyModifiers::CONTROL,                ..            } => {                popup.move_up();                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Down,                ..            }            | KeyEvent {                code: KeyCode::Char('n'),                modifiers: KeyModifiers::CONTROL,                ..            } => {                popup.move_down();                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Esc, ..            } => {                // Dismiss the slash popup; keep the current input untouched.                self.popups.active = ActivePopup::None;                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Tab, ..            } => {                // Ensure popup filtering/selection reflects the latest composer text                // before applying completion.                let text = self.draft.textarea.text();                let first_line = text.lines().next().unwrap_or("").to_owned();                let cursor = self.draft.textarea.cursor();                let filter_text = command_popup_filter_text(&first_line, cursor)                    .unwrap_or_else(|| first_line.clone());                popup.on_composer_text_change(filter_text);                if let Some(selected_cmd) = popup.selected_item() {                    if selected_command_dispatches_immediately_on_tab(&selected_cmd)                        && let CommandItem::Builtin(cmd) = &selected_cmd                    {                        self.stage_selected_slash_command_history(&selected_cmd);                        self.draft.textarea.set_text_clearing_elements("");                        self.draft.is_bash_mode = false;                        return (InputResult::Command(*cmd), true);                    }                    if self                        .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(                            &selected_cmd,                        )                    {                        return (InputResult::None, true);                    }                    if let Some(completed_text) =                        selected_command_completion(&first_line, &selected_cmd)                    {                        self.draft                            .textarea                            .set_text_clearing_elements(&completed_text);                        if !self.draft.textarea.text().is_empty() {                            self.draft                                .textarea                                .set_cursor(self.draft.textarea.text().len());                        }                        return (InputResult::None, true);                    }                }                if self.is_task_running {                    return self.handle_submission(/*should_queue*/ true);                }                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Char('/'),                modifiers: KeyModifiers::NONE,                ..            } => {                // Treat "/" as accepting the highlighted command as text completion                // while the slash-command popup is active.                let text = self.draft.textarea.text();                let first_line = text.lines().next().unwrap_or("").to_owned();                let cursor = self.draft.textarea.cursor();                let filter_text = command_popup_filter_text(&first_line, cursor)                    .unwrap_or_else(|| first_line.clone());                popup.on_composer_text_change(filter_text);                if let Some(selected_cmd) = popup.selected_item() {                    if self                        .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(                            &selected_cmd,                        )                    {                        return (InputResult::None, true);                    }                    if let Some(completed_text) =                        selected_command_completion(&first_line, &selected_cmd)                    {                        self.draft                            .textarea                            .set_text_clearing_elements(&completed_text);                        self.draft.is_bash_mode = false;                    }                    if !self.draft.textarea.text().is_empty() {                        self.draft                            .textarea                            .set_cursor(self.draft.textarea.text().len());                    }                }                (InputResult::None, true)            }            KeyEvent {                code: KeyCode::Enter,                modifiers: KeyModifiers::NONE,                ..            } => {                if let Some(sel) = popup.selected_item() {                    if self                        .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(                            &sel,                        )                        && let Some(result) = self.try_dispatch_slash_command_with_args()                    {                        return (result, true);                    }                    self.stage_selected_slash_command_history(&sel);                    self.draft.textarea.set_text_clearing_elements("");                    self.draft.is_bash_mode = false;                    return (                        match sel {                            CommandItem::Builtin(cmd) => InputResult::Command(cmd),                            CommandItem::ServiceTier(command) => {                                InputResult::ServiceTierCommand(command)                            }                        },                        true,                    );                }                // Fallback to default newline handling if no command selected.                self.handle_key_event_without_popup(key_event)            }            input => self.handle_input_basic(input),        }    }    fn complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(        &mut self,        selected_cmd: &CommandItem,    ) -> bool {        let CommandItem::Builtin(cmd) = selected_cmd else {            return false;        };        if !cmd.supports_inline_args() {            return false;        }        let text = self.draft.textarea.text();        let first_line_end = text.find('\n').unwrap_or(text.len());        let cursor = self.draft.textarea.cursor();        if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) {            return false;        }        let command_token_end = text[1..first_line_end]            .find(char::is_whitespace)            .map(|idx| 1 + idx)            .unwrap_or(first_line_end);        let typed_command_name = &text[1..command_token_end];        let rest_after_token_is_empty = text[command_token_end..].trim().is_empty();        if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) {            return false;        }        let replace_end =            if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) {                command_token_end            } else {                cursor            };        let tail = &text[replace_end..];        let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace);        let selected_command_text = format!("/{}", cmd.command());        let replacement = if tail_starts_with_whitespace {            selected_command_text        } else {            format!("{selected_command_text} ")        };        let ranges_to_unmark = self            .draft            .textarea            .text_elements()            .into_iter()            .filter_map(|element| {                let range = element.byte_range.start..element.byte_range.end;                (range.start < replace_end && replace_end < range.end).then_some(range)            })            .collect::<Vec<_>>();        for range in ranges_to_unmark {            self.draft.textarea.remove_element_range(range);        }        self.draft            .textarea            .replace_range(0..replace_end, &replacement);        self.draft.is_bash_mode = false;        self.draft            .textarea            .set_cursor(self.draft.textarea.text().len());        true    }    /// Keep slash command elements aligned with the current first line.    pub(super) fn sync_slash_command_elements(&mut self) {        if !self.slash_commands_enabled() {            return;        }        let text = self.draft.textarea.text();        let first_line_end = text.find('\n').unwrap_or(text.len());        let first_line = &text[..first_line_end];        let cursor = self.draft.textarea.cursor();        let desired_range = self.slash_input().command_element_range(first_line, cursor);        // Slash commands are only valid at byte 0 of the first line.        // Any slash-shaped element not matching the current desired prefix is stale.        let mut has_desired = false;        let mut stale_ranges = Vec::new();        for elem in self.draft.textarea.text_elements() {            let Some(payload) = elem.placeholder(text) else {                continue;            };            if payload.strip_prefix('/').is_none() {                continue;            }            let range = elem.byte_range.start..elem.byte_range.end;            if desired_range.as_ref() == Some(&range) {                has_desired = true;            } else {                stale_ranges.push(range);            }        }        for range in stale_ranges {            self.draft.textarea.remove_element_range(range);        }        if let Some(range) = desired_range            && !has_desired        {            self.draft.textarea.add_element_range(range);        }    }}pub(super) fn selected_command_dispatches_immediately_on_tab(command: &CommandItem) -> bool {    matches!(command, CommandItem::Builtin(SlashCommand::Skills))}pub(super) fn selected_command_completion(    first_line: &str,    command: &CommandItem,) -> Option<String> {    let selected_command_text = format!("/{}", command.command());    (!first_line.trim_start().starts_with(&selected_command_text))        .then(|| format!("{selected_command_text} "))}pub(super) fn prepared_args(prepared_text: &str) -> Option<(&str, usize)> {    let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(prepared_text)?;    Some((prepared_rest, prepared_rest_offset))}/// Translate full-text element ranges into command-argument ranges.////// `rest_offset` is the byte offset where `rest` begins in the full text.pub(super) fn args_elements(    rest: &str,    rest_offset: usize,    text_elements: &[TextElement],) -> Vec<TextElement> {    if rest.is_empty() || text_elements.is_empty() {        return Vec::new();    }    text_elements        .iter()        .filter_map(|elem| {            if elem.byte_range.end <= rest_offset {                return None;            }            let start = elem.byte_range.start.saturating_sub(rest_offset);            let mut end = elem.byte_range.end.saturating_sub(rest_offset);            if start >= rest.len() {                return None;            }            end = end.min(rest.len());            (start < end).then_some(elem.map_range(|_| ByteRange { start, end }))        })        .collect()}pub(super) fn command_popup_filter_text(first_line: &str, cursor: usize) -> Option<String> {    let (name, _rest) = command_under_cursor(first_line, cursor)?;    Some(format!("/{name}"))}/// If the cursor is currently within a slash command on the first line,/// extract the command fragment before the cursor and the rest of the line after it.fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> {    if !first_line.starts_with('/') {        return None;    }    if cursor > first_line.len() || !first_line.is_char_boundary(cursor) {        return None;    }    let name_start = 1usize;    let name_end = first_line[name_start..]        .find(char::is_whitespace)        .map(|idx| name_start + idx)        .unwrap_or_else(|| first_line.len());    let cursor = if cursor <= name_start {        name_end    } else {        cursor    };    if cursor > name_end {        return None;    }    let name = &first_line[name_start..cursor];    let rest = &first_line[cursor..];    Some((name, rest))}#[cfg(test)]mod tests {    use super::*;    use crate::app_event::AppEvent;    use crate::bottom_pane::AppEventSender;    use pretty_assertions::assert_eq;    use tokio::sync::mpsc::unbounded_channel;    fn test_composer() -> ChatComposer {        let (tx, _rx) = unbounded_channel::<AppEvent>();        ChatComposer::new(            /*has_input_focus*/ true,            AppEventSender::new(tx),            /*enhanced_keys_supported*/ false,            "Ask Codex to do anything".to_string(),            /*disable_paste_burst*/ false,        )    }    fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult {        composer            .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE))            .0    }    fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer {        let mut composer = test_composer();        composer.draft.textarea.set_text_clearing_elements(text);        composer.draft.textarea.set_cursor(cursor);        composer.sync_popups();        composer    }    fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer {        composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len())    }    #[test]    fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() {        let draft = "view the diff";        let expected_text = "/review view the diff";        let mut composer = composer_with_draft_tail("/re", draft);        assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);        assert_eq!(composer.draft.textarea.text(), expected_text);        assert_eq!(composer.draft.textarea.cursor(), expected_text.len());        let mut composer = composer_with_draft_tail("/re", draft);        assert_eq!(            press(&mut composer, KeyCode::Enter),            InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new())        );        assert_eq!(composer.draft.textarea.text(), expected_text);    }    #[test]    fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() {        let mut composer = composer_with_draft_tail(            "/mo",            "preserve this draft only for opted-in slash commands",        );        assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);        assert_eq!(composer.draft.textarea.text(), "/model ");        assert_eq!(composer.draft.textarea.cursor(), "/model ".len());    }    #[test]    fn slash_completion_does_not_turn_command_suffix_into_args() {        let mut composer = composer_with_text_at_cursor("/review", "/re".len());        assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);        assert_eq!(composer.draft.textarea.text(), "/review ");        let mut composer = composer_with_text_at_cursor("/review", "/re".len());        assert_eq!(            press(&mut composer, KeyCode::Enter),            InputResult::Command(SlashCommand::Review)        );        assert!(composer.draft.textarea.is_empty());    }}