diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a3413dd3be..8fcda378fc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -160,8 +160,6 @@ use super::chat_composer_history::ChatComposerHistory; use super::chat_composer_history::HistoryEntry; use super::chat_composer_history::HistoryEntryResponse; use super::command_popup::CommandItem; -use super::command_popup::CommandPopup; -use super::command_popup::CommandPopupFlags; use super::file_search_popup::FileSearchPopup; use super::footer::CollaborationModeIndicator; use super::footer::FooterKeyHints; @@ -197,10 +195,7 @@ use super::skill_popup::SkillPopup; use super::slash_commands::BuiltinCommandFlags; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; -use super::slash_commands::find_slash_command; -use super::slash_commands::has_slash_command_prefix; use crate::bottom_pane::paste_burst::FlushResult; -use crate::bottom_pane::prompt_args::parse_slash_name; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; @@ -222,6 +217,7 @@ mod draft_state; mod footer_state; mod history_search; mod popup_state; +mod slash_input; use self::attachment_state::AttachmentState; use self::draft_state::ComposerMentionBinding; @@ -230,6 +226,9 @@ use self::footer_state::FooterState; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; use self::popup_state::PopupState; +use self::slash_input::SlashInput; +use self::slash_input::SlashValidation; +use self::slash_input::SubmissionValidation; use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; @@ -411,12 +410,6 @@ pub(crate) struct ComposerDraftSnapshot { pub(crate) pending_pastes: Vec<(String, String)>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SlashValidation { - Immediate, - Deferred, -} - const FOOTER_SPACING_HEIGHT: u16 = 0; /// Builds the one-line nudge that replaces the ambient footer without adding layout height. @@ -433,6 +426,15 @@ fn plan_mode_nudge_line() -> Line<'static> { } impl ChatComposer { + fn slash_input(&self) -> SlashInput<'_> { + SlashInput::new( + self.slash_commands_enabled(), + self.draft.is_bash_mode, + self.builtin_command_flags(), + &self.service_tier_commands, + ) + } + fn builtin_command_flags(&self) -> BuiltinCommandFlags { BuiltinCommandFlags { collaboration_modes_enabled: self.collaboration_modes_enabled, @@ -1653,145 +1655,6 @@ impl ChatComposer { self.history_search.is_some() || self.popups.active() } - /// Handle key event when the slash-command popup is visible. - 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 first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); - if let Some(selected_cmd) = popup.selected_item() { - let selected_command_text = format!("/{}", selected_cmd.command()); - if let CommandItem::Builtin(cmd) = selected_cmd - && cmd == SlashCommand::Skills - { - self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd)); - self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; - return (InputResult::Command(cmd), true); - } - - let starts_with_cmd = - first_line.trim_start().starts_with(&selected_command_text); - if !starts_with_cmd { - self.draft - .textarea - .set_text_clearing_elements(&format!("{selected_command_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 first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); - if let Some(selected_cmd) = popup.selected_item() { - let selected_command_text = format!("/{}", selected_cmd.command()); - let starts_with_cmd = - first_line.trim_start().starts_with(&selected_command_text); - if !starts_with_cmd { - self.draft - .textarea - .set_text_clearing_elements(&format!("{selected_command_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() { - 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), - } - } - #[inline] fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { let mut p = pos.min(text.len()); @@ -2721,37 +2584,27 @@ impl ChatComposer { text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); if slash_validation == SlashValidation::Immediate - && self.slash_commands_enabled() - && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) + && let SubmissionValidation::UnknownCommand(name) = self + .slash_input() + .validate_submission(&text, input_starts_with_space) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_known = find_slash_command( - name, - self.builtin_command_flags(), - &self.service_tier_commands, - ) - .is_some(); - if !is_known { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, /*hint*/ None), - ))); - self.set_text_content_with_mention_bindings( - original_input.clone(), - original_text_elements, - original_local_image_paths, - original_mention_bindings, - ); - self.draft - .pending_pastes - .clone_from(&original_pending_pastes); - self.draft.textarea.set_cursor(original_input.len()); - return None; - } - } + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, /*hint*/ None), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); + return None; } let actual_chars = text.chars().count(); @@ -2823,8 +2676,7 @@ impl ChatComposer { self.handle_paste(pasted); } let raw_text = self.draft.textarea.text(); - let defer_slash_validation = - self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); + let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text); if let Some((text, text_elements)) = self.prepare_submission_text_with_options( /*record_history*/ true, if defer_slash_validation { @@ -2833,7 +2685,7 @@ impl ChatComposer { SlashValidation::Immediate }, ) { - let action = self.queued_input_action(&text, defer_slash_validation); + let action = slash_input::queued_input_action(&text, defer_slash_validation); return ( InputResult::Queued { text, @@ -2940,25 +2792,9 @@ 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() || self.draft.is_bash_mode { - return None; - } - let text = self.draft.textarea.text(); - 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 = find_slash_command( - name, - self.builtin_command_flags(), - &self.service_tier_commands, - )?; - if command.supports_inline_args() - && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty()) - { - return None; - } + let command = self + .slash_input() + .bare_command(self.draft.textarea.text())?; if self.reject_slash_command_if_unavailable(&command) { self.stage_slash_command_history(&command); self.record_pending_slash_command_history(); @@ -2976,28 +2812,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() || self.draft.is_bash_mode { - return None; - } let text = self.draft.textarea.text().to_string(); - if text.starts_with(' ') { - return None; - } - - let (name, rest, rest_offset) = parse_slash_name(&text)?; - if rest.is_empty() || name.contains('/') { - return None; - } - - let command = find_slash_command( - name, - self.builtin_command_flags(), - &self.service_tier_commands, - )?; - - if !command.supports_inline_args() { - return None; - } + let inline_command = self.slash_input().inline_command(&text)?; + let command = inline_command.command; if self.reject_slash_command_if_unavailable(&command) { self.stage_slash_command_history(&command); self.record_pending_slash_command_history(); @@ -3006,13 +2823,13 @@ impl ChatComposer { self.stage_slash_command_history(&command); - let mut args_elements = Self::slash_command_args_elements( - rest, - rest_offset, + let mut args_elements = slash_input::args_elements( + inline_command.rest, + inline_command.rest_offset, &self.draft.textarea.text_elements(), ); - let trimmed_rest = rest.trim(); - args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); + let trimmed_rest = inline_command.rest.trim(); + args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements); let SlashCommandItem::Builtin(cmd) = command else { return None; }; @@ -3038,12 +2855,9 @@ impl ChatComposer { record_history: bool, ) -> Option<(String, Vec)> { let (prepared_text, prepared_elements) = self.prepare_submission_text(record_history)?; - let (_, prepared_rest, prepared_rest_offset) = parse_slash_name(&prepared_text)?; - let mut args_elements = Self::slash_command_args_elements( - prepared_rest, - prepared_rest_offset, - &prepared_elements, - ); + let (prepared_rest, prepared_rest_offset) = slash_input::prepared_args(&prepared_text)?; + let mut args_elements = + slash_input::args_elements(prepared_rest, prepared_rest_offset, &prepared_elements); let trimmed_rest = prepared_rest.trim(); args_elements = Self::trim_text_elements(prepared_rest, trimmed_rest, args_elements); Some((trimmed_rest.to_string(), args_elements)) @@ -3063,24 +2877,6 @@ impl ChatComposer { true } - fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool { - self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/') - } - - fn queued_input_action( - &self, - 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 - } - } - /// Stage the current slash-command text for later local recall. /// /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` @@ -3120,34 +2916,6 @@ impl ChatComposer { }); } - /// Translate full-text element ranges into command-argument ranges. - /// - /// `rest_offset` is the byte offset where `rest` begins in the full text. - fn slash_command_args_elements( - rest: &str, - rest_offset: usize, - text_elements: &[TextElement], - ) -> Vec { - 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() - } - fn handle_remote_image_selection_key( &mut self, key_event: &KeyEvent, @@ -3715,123 +3483,6 @@ impl ChatComposer { } } - /// Keep slash command elements aligned with the current first line. - 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 desired_range = self.slash_command_element_range(first_line); - // 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); - } - } - - fn slash_command_element_range(&self, first_line: &str) -> Option> { - if self.draft.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(); - 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; - } - if self.is_known_slash_name(name) { - Some(0..element_end) - } else { - None - } - } - - fn is_known_slash_name(&self, name: &str) -> bool { - find_slash_command( - name, - self.builtin_command_flags(), - &self.service_tier_commands, - ) - .is_some() - } - - /// If the cursor is currently within a slash command on the first line, - /// extract the command name and the rest of the line after it. - /// Returns None if the cursor is outside a slash command. - fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { - if !first_line.starts_with('/') { - 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()); - - if cursor > name_end { - return None; - } - - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; - - Some((name, rest)) - } - - /// Heuristic for whether the typed slash command looks like a valid - /// prefix for any known built-in command. - /// 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(); - } - - has_slash_command_prefix( - name, - self.builtin_command_flags(), - &self.service_tier_commands, - ) - } - /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -3850,8 +3501,9 @@ impl ChatComposer { let caret_on_first_line = cursor <= first_line_end; let is_editing_slash_command_name = caret_on_first_line - && Self::slash_command_under_cursor(first_line, cursor) - .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); + && self + .slash_input() + .is_editing_command_name(first_line, cursor); // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path @@ -3872,30 +3524,7 @@ impl ChatComposer { } _ => { if is_editing_slash_command_name { - let collaboration_modes_enabled = self.collaboration_modes_enabled; - let connectors_enabled = self.connectors_enabled; - let plugins_command_enabled = self.plugins_command_enabled; - let service_tier_commands_enabled = self.service_tier_commands_enabled; - let goal_command_enabled = self.goal_command_enabled; - let personality_command_enabled = self.personality_command_enabled; - let realtime_conversation_enabled = self.realtime_conversation_enabled; - let audio_device_selection_enabled = self.audio_device_selection_enabled; - let mut command_popup = CommandPopup::new( - CommandPopupFlags { - collaboration_modes_enabled, - connectors_enabled, - plugins_command_enabled, - service_tier_commands_enabled, - goal_command_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, - side_conversation_active: self.side_conversation_active, - }, - self.service_tier_commands.clone(), - ); - command_popup.on_composer_text_change(first_line.to_string()); + let command_popup = self.slash_input().command_popup(first_line); self.popups.active = ActivePopup::Command(command_popup); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs new file mode 100644 index 0000000000..a7205d237a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -0,0 +1,452 @@ +//! 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 { + 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> { + 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) -> Option> { + 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(); + 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, first_line: &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, + 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, + realtime_conversation_enabled: self.command_flags.realtime_conversation_enabled, + audio_device_selection_enabled: self.command_flags.audio_device_selection_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(first_line.to_string()); + command_popup + } + + fn command(&self, name: &str) -> Option { + 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 first_line = self.draft.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); + 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 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 first_line = self.draft.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); + if let Some(selected_cmd) = popup.selected_item() { + 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() { + 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), + } + } + + /// 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 desired_range = self.slash_input().command_element_range(first_line); + // 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 { + 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 { + 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() +} + +/// If the cursor is currently within a slash command on the first line, +/// extract the command name 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; + } + + 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()); + + if cursor > name_end { + return None; + } + + let name = &first_line[name_start..name_end]; + let rest_start = first_line[name_end..] + .find(|c: char| !c.is_whitespace()) + .map(|idx| name_end + idx) + .unwrap_or(name_end); + let rest = &first_line[rest_start..]; + + Some((name, rest)) +}