diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index a68938e517..511240d380 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -1586,9 +1586,10 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { // A fresh composer click takes ownership away from the transcript selection. app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?; assert!(!app.transcript_view.has_active_interaction()); - app.chat_widget.apply_external_edit("/".into()); + app.chat_widget.apply_external_edit("/model".into()); assert!(!app.chat_widget.no_modal_or_popup_active()); - assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?); + let event = mouse(Down(Left), x, y); + assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?); app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?; assert!(app.chat_widget.no_modal_or_popup_active()); app.chat_widget.open_feature_enable_prompt(Feature::Collab); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap index 20e5d5c268..9dc62f57ba 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap @@ -12,7 +12,7 @@ Transcript row 34: content behind the menu /mention mention a file /mcp list MCP tools; use /mcp verbose or /mcp login -› /m +/ m gpt-test default · /tmp/project model high · fast diff --git a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs index b762305b4a..966c3d6a81 100644 --- a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs +++ b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs @@ -120,6 +120,12 @@ async fn unavailable_thread_routes_local_and_recovery_commands() -> Result<()> { TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), ) .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyCode::Backspace.into()), + ) + .await?; app.chat_widget .restore_user_message_to_composer(draft.into()); app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(KeyCode::Enter.into())) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 49b13b5360..756c2d6840 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -20,8 +20,11 @@ //! //! Preset submenu hints borrow slots and reflow with the composer. //! -//! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab -//! submit ordinary text without enabling shell mode. +//! Enabled `!` and `/` command prefixes live in prompt state rather than the editable textarea; +//! canonical draft APIs reconstruct them for submission, queuing, history, and external editing. +//! Vim Replace keeps the overwritten leading grapheme recoverable until Backspace removes the +//! absorbed prefix. +//! The plain-text preset keeps both prefixes literal, so Enter and Tab submit ordinary text. //! //! # Mention Menus //! @@ -306,7 +309,6 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; @@ -401,6 +403,7 @@ mod warning_notice; use self::attachment_state::AttachmentState; use self::draft_state::ComposerMentionBinding; use self::draft_state::DraftState; +use self::draft_state::PromptPrefix; pub(crate) use self::footer_state::CommandPopupPlacement; use self::footer_state::ComposerLayout; pub(crate) use self::footer_state::ComposerRenderOptions; @@ -444,6 +447,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use unicode_segmentation::UnicodeSegmentation; use ratatui::style::Color; @@ -696,7 +700,7 @@ impl ChatComposer { fn slash_input(&self) -> SlashInput<'_> { SlashInput::new( self.slash_commands_enabled(), - self.draft.is_bash_mode, + self.draft.prompt_prefix == PromptPrefix::Shell, self.builtin_command_flags(), &self.service_tier_commands, ) @@ -959,11 +963,13 @@ impl ChatComposer { } pub fn set_plugins_command_enabled(&mut self, enabled: bool) { - self.plugins_command_enabled = enabled; + self.update_slash_command_catalog(|composer| composer.plugins_command_enabled = enabled); } pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { - self.token_activity_command_enabled = enabled; + self.update_slash_command_catalog(|composer| { + composer.token_activity_command_enabled = enabled + }); } pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { @@ -1005,28 +1011,37 @@ impl ChatComposer { } pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { - self.collaboration_modes_enabled = enabled; + self.update_slash_command_catalog(|composer| { + composer.collaboration_modes_enabled = enabled + }); } pub fn set_connectors_enabled(&mut self, enabled: bool) { - self.connectors_enabled = enabled; + self.update_slash_command_catalog(|composer| composer.connectors_enabled = enabled); } pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { - self.service_tier_commands_enabled = enabled; + self.update_slash_command_catalog(|composer| { + composer.service_tier_commands_enabled = enabled + }); } pub fn set_service_tier_commands(&mut self, commands: Vec) { - self.service_tier_commands = commands; - self.sync_popups(); + self.update_slash_command_catalog(|composer| composer.service_tier_commands = commands); } pub fn set_goal_command_enabled(&mut self, enabled: bool) { - self.goal_command_enabled = enabled; + self.update_slash_command_catalog(|composer| composer.goal_command_enabled = enabled); } pub fn set_voice_command_enabled(&mut self, enabled: bool) { - self.voice_command_enabled = enabled; + self.update_slash_command_catalog(|composer| composer.voice_command_enabled = enabled); + } + + fn update_slash_command_catalog(&mut self, update: impl FnOnce(&mut Self)) { + self.unmark_slash_command_element(); + update(self); + self.sync_popups(); } /// Refresh the event destination when a retained editor survives reconnection. @@ -1111,7 +1126,7 @@ impl ChatComposer { } pub fn set_side_conversation_active(&mut self, active: bool) { - self.side_conversation_active = active; + self.update_slash_command_catalog(|composer| composer.side_conversation_active = active); } /// Compatibility shim for tests that still toggle the removed steer mode flag. @@ -1131,7 +1146,9 @@ impl ChatComposer { } #[cfg(target_os = "windows")] pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { - self.windows_degraded_sandbox_active = enabled; + self.update_slash_command_catalog(|composer| { + composer.windows_degraded_sandbox_active = enabled + }); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { @@ -1140,7 +1157,9 @@ impl ChatComposer { /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { - self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() + self.draft.textarea.is_empty() + && self.draft.prompt_prefix == PromptPrefix::None + && self.attachments.is_empty() } /// Record local persistent-history metadata so the composer can navigate @@ -1549,7 +1568,7 @@ impl ChatComposer { self.vim_history = VimHistory::default(); self.draft.textarea_state.get_mut().follow_cursor(); self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; self.draft.pending_pastes.clear(); self.draft.mention_bindings.clear(); @@ -1566,7 +1585,7 @@ impl ChatComposer { } pub(crate) fn current_cursor(&self) -> usize { - self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } + self.draft.textarea.cursor() + self.draft.prompt_prefix.len() } #[cfg(test)] @@ -1575,7 +1594,7 @@ impl ChatComposer { } fn history_navigation_cursor(&self) -> usize { - if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { + if self.draft.prompt_prefix != PromptPrefix::None && self.draft.textarea.cursor() == 0 { 0 } else if self.draft.textarea.is_vim_normal_mode() && !self.draft.textarea.text().is_empty() @@ -1588,22 +1607,27 @@ impl ChatComposer { } pub(crate) fn set_current_cursor(&mut self, cursor: usize) { - let visible_cursor = if self.draft.is_bash_mode { - cursor.saturating_sub(1) - } else { - cursor - }; + let visible_cursor = cursor.saturating_sub(self.draft.prompt_prefix.len()); self.draft .textarea .set_cursor(visible_cursor.min(self.draft.textarea.text().len())); } fn current_text_elements(&self) -> Vec { - let shift = if self.draft.is_bash_mode { 1 } else { 0 }; + let shift = self.draft.prompt_prefix.len() as isize; + let command_range = self + .draft + .slash_command_element_id + .and_then(|id| self.draft.textarea.element_range(id)); self.draft .textarea .text_elements() .into_iter() + .filter(|element| { + command_range.as_ref().is_none_or(|range| { + element.byte_range.start != range.start || element.byte_range.end != range.end + }) + }) .filter_map(|element| Self::shift_text_element(element, shift)) .collect() } @@ -1682,30 +1706,37 @@ impl ChatComposer { /// Convert canonical composer text into the textarea's internal representation. /// - /// Shell mode stores the leading `!` as prompt state instead of editable text, + /// Command modes store the leading `!` or `/` as prompt state instead of editable text, /// so full-buffer imports must absorb that prefix before rebuilding the textarea. fn imported_text_for_textarea( &mut self, text: String, text_elements: Vec, ) -> (String, Vec) { - if self.config.shell_commands_enabled - && let Some(stripped) = text.strip_prefix('!') - { - self.draft.is_bash_mode = true; + let prefix = self.enabled_prompt_prefix_for_text(&text); + self.draft.prompt_prefix = prefix; + if prefix != PromptPrefix::None { ( - stripped.to_string(), + text[prefix.len()..].to_string(), text_elements .into_iter() - .filter_map(|element| Self::shift_text_element(element, /*shift*/ -1)) + .filter_map(|element| { + Self::shift_text_element(element, -(prefix.len() as isize)) + }) .collect(), ) } else { - self.draft.is_bash_mode = false; (text, text_elements) } } + fn set_canonical_text_clearing_elements(&mut self, text: String) { + let (text, text_elements) = self.imported_text_for_textarea(text, Vec::new()); + self.draft + .textarea + .set_text_with_elements(&text, &text_elements); + } + /// Flush buffered typing before clearing so cancellation preserves the complete draft in history. pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { @@ -1736,11 +1767,11 @@ impl ChatComposer { /// Get the current composer text. pub(crate) fn current_text(&self) -> String { - if self.draft.is_bash_mode { - format!("!{}", self.draft.textarea.text()) - } else { - self.draft.textarea.text().to_string() - } + format!( + "{}{}", + self.draft.prompt_prefix.text(), + self.draft.textarea.text() + ) } /// Recall content at its history boundary, preserving pending pastes and omitting images @@ -2992,7 +3023,7 @@ impl ChatComposer { let input_starts_with_space = original_input.starts_with(' '); self.draft.recent_submission_mention_bindings.clear(); self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; if pending_paste_handling == PendingPasteHandling::Expand && !self.draft.pending_pastes.is_empty() @@ -3156,11 +3187,11 @@ impl ChatComposer { let original_input = self.current_text(); let original_text_elements = self.current_text_elements(); let original_pending_pastes = self.draft.pending_pastes.clone(); - let raw_text = self.draft.textarea.text(); - let defer_slash_validation = self.slash_input().should_parse_on_dequeue(raw_text); + let defer_slash_validation = + self.slash_input().should_parse_on_dequeue(&original_input); let preserve_pending_pastes = defer_slash_validation && !self.draft.pending_pastes.is_empty() - && parse_slash_name(raw_text) + && parse_slash_name(&original_input) .is_some_and(|(name, _, _)| name == SlashCommand::Goal.command()); let pending_pastes = if preserve_pending_pastes { self.draft.pending_pastes.clone() @@ -3296,9 +3327,7 @@ 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 { - let command = self - .slash_input() - .bare_command(self.draft.textarea.text())?; + let command = self.slash_input().bare_command(&self.current_text())?; if self.reject_slash_command_if_unavailable(&command) { self.stage_slash_command_history(&command); self.record_pending_slash_command_history(); @@ -3308,7 +3337,7 @@ impl ChatComposer { if !matches!(command, SlashCommandItem::Builtin(cmd) if cmd.requires_dispatch_validation()) { self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; } Some(match command { SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), @@ -3319,7 +3348,7 @@ 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 { - let text = self.draft.textarea.text().to_string(); + let text = self.current_text(); let inline_command = self.slash_input().inline_command(&text)?; let command = inline_command.command; if self.reject_slash_command_if_unavailable(&command) { @@ -3333,7 +3362,7 @@ impl ChatComposer { let mut args_elements = slash_input::args_elements( inline_command.rest, inline_command.rest_offset, - &self.draft.textarea.text_elements(), + &self.current_text_elements(), ); let trimmed_rest = inline_command.rest.trim(); args_elements = Self::trim_text_elements(inline_command.rest, trimmed_rest, args_elements); @@ -3397,7 +3426,7 @@ impl ChatComposer { if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { return; } - self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string()); + self.stage_slash_command_history_text(self.current_text().trim().to_string()); } /// Stage a popup-selected command using its canonical command text. @@ -3419,7 +3448,7 @@ impl ChatComposer { fn stage_slash_command_history_text(&mut self, text: String) { self.pending_slash_command_history = Some(HistoryEntry { text, - text_elements: self.draft.textarea.text_elements(), + text_elements: self.current_text_elements(), local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), @@ -3457,12 +3486,12 @@ impl ChatComposer { if self.handle_empty_prompt_shortcut(&key_event) { return (InputResult::None, true); } - if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { + if self.draft.prompt_prefix == PromptPrefix::Shell && key_event.code == KeyCode::Esc { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.apply_paste(pasted); } if self.draft.textarea.is_empty() { - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; return (InputResult::None, true); } } @@ -3487,7 +3516,7 @@ impl ChatComposer { ) { self.footer.mode = reset_mode_after_activity(self.footer.mode); - self.draft.is_bash_mode = true; + self.draft.prompt_prefix = PromptPrefix::Shell; self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } @@ -3711,13 +3740,18 @@ impl ChatComposer { Some(self.draft.textarea.element_payloads()) }; - if self.draft.is_bash_mode + if self.draft.prompt_prefix != PromptPrefix::None && self.draft.textarea.vim_query().is_none() - && matches!(input.code, KeyCode::Backspace) + && self.draft.textarea.mouse_selection_range().is_none() + && self.draft.textarea.is_delete_backward_key(input) && self.draft.textarea.cursor() == 0 { let started_vim_edit = self.begin_direct_vim_edit(); - self.draft.is_bash_mode = false; + if self.draft.textarea.is_vim_replace_mode() { + self.draft.textarea.input(input); + } + self.draft.prompt_prefix = PromptPrefix::None; + self.sync_popups(); if started_vim_edit { self.finish_vim_edit(); } @@ -3726,7 +3760,7 @@ impl ChatComposer { self.begin_vim_edit(input); self.draft.textarea.input(input); - self.sync_bash_mode_from_text(); + self.sync_prompt_prefix_from_text(); if let Some(elements_before) = elements_before { self.reconcile_deleted_elements(elements_before); @@ -3752,13 +3786,21 @@ impl ChatComposer { (InputResult::None, true) } - fn sync_bash_mode_from_text(&mut self) { - if self.config.shell_commands_enabled - && !self.draft.is_bash_mode - && self.draft.textarea.text().starts_with('!') - { - self.draft.textarea.replace_range(0..1, ""); - self.draft.is_bash_mode = true; + fn sync_prompt_prefix_from_text(&mut self) { + if self.draft.prompt_prefix == PromptPrefix::None { + let prefix = self.enabled_prompt_prefix_for_text(self.draft.textarea.text()); + if prefix != PromptPrefix::None { + self.draft.textarea.absorb_prompt_prefix(prefix.len()); + self.draft.prompt_prefix = prefix; + } + } + } + + fn enabled_prompt_prefix_for_text(&self, text: &str) -> PromptPrefix { + match text.graphemes(/*is_extended*/ true).next() { + Some("!") if self.config.shell_commands_enabled => PromptPrefix::Shell, + Some("/") if self.slash_commands_enabled() => PromptPrefix::Slash, + _ => PromptPrefix::None, } } @@ -3876,7 +3918,7 @@ impl ChatComposer { } let allow_command_popup = self.slash_commands_enabled() - && !self.draft.is_bash_mode + && self.draft.prompt_prefix != PromptPrefix::Shell && file_token.is_none() && mentions_v2_token.is_none() && mention_target.is_none(); @@ -3932,7 +3974,7 @@ impl ChatComposer { /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. fn sync_command_popup(&mut self, allow: bool) { - let text = self.draft.textarea.text(); + let text = self.current_text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; // Keep an explicitly dismissed popup closed until the command token changes. @@ -3951,7 +3993,7 @@ impl ChatComposer { return; } // Determine whether the caret is inside the initial '/name' token on the first line. - let cursor = self.draft.textarea.cursor(); + let cursor = self.current_cursor(); let caret_on_first_line = cursor <= first_line_end; let is_editing_slash_command_name = caret_on_first_line @@ -4841,8 +4883,10 @@ impl ChatComposer { } if !textarea_rect.is_empty() { let prompt = if self.draft.input_enabled { - if self.draft.is_bash_mode { - Span::from("!").light_red().bold() + if self.draft.prompt_prefix == PromptPrefix::Shell { + "!".light_red().bold() + } else if self.draft.prompt_prefix == PromptPrefix::Slash { + "/".fg(crate::style::accent_color_on(style.bg)).bold() } else if self.luna_reserve_active { // Reserve keeps one arrow at every reasoning effort; only its foreground changes. "›" @@ -4873,7 +4917,8 @@ impl ChatComposer { if options.max_height.is_none() { state.follow_cursor(); } - let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; + let textarea_is_empty = + self.draft.textarea.text().is_empty() && self.draft.prompt_prefix == PromptPrefix::None; if self.draft.input_enabled { if let Some(mask_char) = mask_char { self.draft @@ -4889,22 +4934,13 @@ impl ChatComposer { .chain(self.draft.textarea.vim_search_highlights()) .map(|range| (range, search_highlight_style)), ); - if highlights.is_empty() { - StatefulWidgetRef::render_ref( - &(&self.draft.textarea), - textarea_rect, - buf, - &mut state, - ); - } else { - self.draft.textarea.render_ref_styled_with_highlights( - textarea_rect, - buf, - &mut state, - Style::default(), - &highlights, - ); - } + self.draft.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + Style::default(), + &highlights, + ); } } if !self.draft.input_enabled || textarea_is_empty { @@ -5413,6 +5449,46 @@ mod tests { assert_eq!(composer.cursor_pos(area), Some((6, 1))); } + #[test] + fn slash_command_cursor_uses_absorbed_prefix() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 40, 5); + + composer.set_text_content("/help".to_string(), Vec::new(), Vec::new()); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); + assert_eq!(composer.draft.textarea.text(), "help"); + composer.draft.textarea.set_cursor(/*pos*/ 0); + let before_command = composer.cursor_pos(area); + composer.draft.textarea.set_cursor(/*pos*/ 1); + let after_first_character = composer.cursor_pos(area); + + assert_eq!( + (before_command, after_first_character), + (Some((2, 1)), Some((3, 1))) + ); + composer.set_text_content("/\u{301}help".to_string(), Vec::new(), Vec::new()); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + assert_eq!(buffer[(2, 1)].symbol(), "/\u{301}"); + composer.draft.textarea.set_cursor(/*pos*/ 0); + assert_eq!( + ( + composer.cursor_pos(Rect::new(0, 0, 40, 1)), + composer.cursor_pos(Rect::new(0, 0, 0, 5)), + ), + (None, None) + ); + } + #[test] fn shell_command_uses_shell_accent_style() { let (tx, _rx) = unbounded_channel::(); @@ -5637,7 +5713,7 @@ mod tests { ); type_chars_humanlike(&mut composer, &['!']); - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.current_text(), "!"); let (result, needs_redraw) = @@ -5645,10 +5721,38 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None); assert_eq!(composer.current_text(), ""); } + #[test] + fn backspace_removes_absorbed_prompt_prefixes() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + + for prefix in ["!", "/"] { + composer.set_text_content(prefix.to_string(), Vec::new(), Vec::new()); + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(needs_redraw); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None); + assert_eq!(composer.current_text(), ""); + } + } + #[test] fn esc_keeps_shell_mode_when_paste_burst_flushes_pending_text() { use crossterm::event::KeyCode; @@ -5675,7 +5779,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.current_text(), "!g"); } @@ -6033,8 +6137,11 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert_eq!(composer.draft.textarea.text(), "/"); - assert_eq!(composer.draft.textarea.cursor(), "/".len()); + assert_eq!(composer.current_text(), "/"); + assert_eq!(composer.draft.textarea.text(), ""); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); + assert_eq!(composer.current_cursor(), "/".len()); + assert_eq!(composer.draft.textarea.cursor(), 0); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); assert_eq!( composer.vim_mode_indicator_span(), @@ -6062,7 +6169,9 @@ mod tests { for ch in ['i', '/', 'd', 'i', 'f', 'f'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.draft.textarea.text(), "/diff"); + assert_eq!(composer.current_text(), "/diff"); + assert_eq!(composer.draft.textarea.text(), "diff"); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); let (result, needs_redraw) = @@ -6145,7 +6254,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.current_text(), "!"); assert_eq!(composer.draft.textarea.text(), ""); assert_eq!( @@ -6175,7 +6284,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.current_text(), "!echo"); assert_eq!(composer.draft.textarea.text(), "echo"); assert!(matches!(composer.popups.active, ActivePopup::None)); @@ -6429,7 +6538,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.draft.textarea.text(), "/not-a-command"); + assert_eq!(composer.current_text(), "/not-a-command"); + assert_eq!(composer.draft.textarea.text(), "not-a-command"); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -9328,7 +9439,7 @@ mod tests { terminal .draw(|f| { composer.render(f.area(), f.buffer_mut()); - assert_eq!(composer.cursor_pos(f.area()), Some((5, 1))); + assert_eq!(composer.cursor_pos(f.area()), Some((4, 1))); }) .expect("draw composer"); @@ -9403,6 +9514,20 @@ mod tests { ); } + #[test] + fn dynamic_slash_command_becomes_editable_and_is_not_exported() { + let (mut composer, _rx) = new_test_composer(); + composer.set_voice_command_enabled(/*enabled*/ true); + type_chars_humanlike(&mut composer, &['/', 'v', 'o', 'i', 'c', 'e', ' ']); + assert_eq!(composer.draft.textarea.text_elements().len(), 1); + assert!(composer.text_elements().is_empty()); + + composer.set_voice_command_enabled(/*enabled*/ false); + + assert_eq!(composer.current_text(), "/voice "); + assert!(composer.draft.textarea.text_elements().is_empty()); + } + fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { std::thread::sleep(PasteBurst::recommended_active_flush_delay()); composer.flush_paste_burst_if_due() @@ -9877,7 +10002,10 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.draft.textarea.text(), "/compact "); + assert_eq!(composer.current_text(), "/compact "); + assert_eq!(composer.draft.textarea.text(), "compact "); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); + assert_eq!(composer.current_cursor(), composer.current_text().len()); assert_eq!( composer.draft.textarea.cursor(), composer.draft.textarea.text().len() @@ -9907,7 +10035,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.current_text(), "/model "); + assert_eq!(composer.draft.textarea.text(), "model "); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); assert_eq!( composer.draft.textarea.cursor(), composer.draft.textarea.text().len() @@ -9936,7 +10066,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.current_text(), "/model "); + assert_eq!(composer.draft.textarea.text(), "model "); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); assert_eq!( composer.draft.textarea.cursor(), composer.draft.textarea.text().len() @@ -9960,7 +10092,9 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'd', 'i']); let (_res, _redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.draft.textarea.text(), "/diff "); + assert_eq!(composer.current_text(), "/diff "); + assert_eq!(composer.draft.textarea.text(), "diff "); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Slash); // Press Enter: should dispatch the command, not submit literal text. let (result, _needs_redraw) = @@ -10004,9 +10138,10 @@ mod tests { let text = composer.draft.textarea.text().to_string(); let elements = composer.draft.textarea.text_elements(); - assert_eq!(text, "/plan "); + assert_eq!(composer.current_text(), "/plan "); + assert_eq!(text, "plan "); assert_eq!(elements.len(), 1); - assert_eq!(elements[0].placeholder(&text), Some("/plan")); + assert_eq!(elements[0].placeholder(&text), Some("plan")); } #[test] @@ -10026,12 +10161,30 @@ mod tests { let text = composer.draft.textarea.text().to_string(); let elements = composer.draft.textarea.text_elements(); - assert_eq!(text, "/Users "); + assert_eq!(composer.current_text(), "/Users "); + assert_eq!(text, "Users "); assert!(elements.is_empty()); } #[test] - fn slash_command_element_removed_when_not_at_start() { + fn unowned_element_spanning_slash_command_is_preserved() { + let (mut composer, _rx) = new_test_composer(); + let text = "/review "; + let element = TextElement::new(ByteRange { start: 1, end: 7 }, Some("review".to_string())); + + composer.set_text_content(text.to_string(), vec![element.clone()], Vec::new()); + composer.move_cursor_to_end(); + + assert_eq!(composer.current_text(), text); + assert_eq!(composer.text_elements(), vec![element.clone()]); + + composer.sync_popups(); + + assert_eq!(composer.text_elements(), vec![element]); + } + + #[test] + fn slash_command_element_removed_when_command_changes() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -10046,15 +10199,17 @@ mod tests { let text = composer.draft.textarea.text().to_string(); let elements = composer.draft.textarea.text_elements(); - assert_eq!(text, "/review "); + assert_eq!(composer.current_text(), "/review "); + assert_eq!(text, "review "); assert_eq!(elements.len(), 1); composer.draft.textarea.set_cursor(/*pos*/ 0); - type_chars_humanlike(&mut composer, &['x']); + type_chars_humanlike(&mut composer, &[' ']); let text = composer.draft.textarea.text().to_string(); let elements = composer.draft.textarea.text_elements(); - assert_eq!(text, "x/review "); + assert_eq!(composer.current_text(), "/ review "); + assert_eq!(text, " review "); assert!(elements.is_empty()); } @@ -11086,7 +11241,7 @@ mod tests { } #[test] - fn history_navigation_from_start_of_bang_command_recalls_older_entry() { + fn history_navigation_from_start_of_slash_prompt_recalls_older_entry() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -11106,14 +11261,17 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::Submitted { .. })); - type_chars_humanlike(&mut composer, &['!', 'g', 'i', 't']); + type_chars_humanlike( + &mut composer, + &['/', 't', 'm', 'p', '/', 'f', 'i', 'l', 'e'], + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::Submitted { .. })); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.current_text(), "!git"); + assert_eq!(composer.current_text(), "/tmp/file"); composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = @@ -11341,13 +11499,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); assert_eq!(composer.draft.pending_pastes.len(), 1); - assert_eq!( - composer.draft.textarea.text(), - format!("/unknown {placeholder}") - ); + assert_eq!(composer.current_text(), format!("/unknown {placeholder}")); composer.draft.textarea.set_cursor(/*pos*/ 0); - composer.draft.textarea.insert_str(" "); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + composer.draft.textarea.insert_str(" /"); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -12136,7 +12292,8 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new()); + type_chars_humanlike(&mut composer, &['/', 'd', 'i', 'f', 'f', ' ']); + assert_eq!(composer.draft.textarea.text_elements().len(), 1); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -12147,6 +12304,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(result, InputResult::None); assert_eq!(composer.current_text(), "/diff"); + assert!(composer.draft.textarea.text_elements().is_empty()); } #[test] @@ -12264,7 +12422,7 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -12284,7 +12442,7 @@ mod tests { composer.apply_external_edit("git status".to_string()); - assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::None); assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "git status"); } @@ -12304,7 +12462,7 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.prompt_prefix, PromptPrefix::Shell); assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs index b219097ee1..ce1bbd021d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -8,10 +8,33 @@ use crate::bottom_pane::paste_burst::PasteBurst; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum PromptPrefix { + #[default] + None, + Shell, + Slash, +} + +impl PromptPrefix { + pub(super) fn text(self) -> &'static str { + match self { + Self::None => "", + Self::Shell => "!", + Self::Slash => "/", + } + } + + pub(super) fn len(self) -> usize { + self.text().len() + } +} + pub(super) struct DraftState { pub(super) textarea: TextArea, pub(super) textarea_state: RefCell, - pub(super) is_bash_mode: bool, + pub(super) prompt_prefix: PromptPrefix, + pub(super) slash_command_element_id: Option, pub(super) pending_pastes: Vec<(String, String)>, pub(super) input_enabled: bool, pub(super) input_disabled_placeholder: Option, @@ -26,7 +49,8 @@ impl DraftState { Self { textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), - is_bash_mode: false, + prompt_prefix: PromptPrefix::None, + slash_command_element_id: None, pending_pastes: Vec::new(), input_enabled: true, input_disabled_placeholder: None, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs index 0a79c52063..ef444a31fd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs @@ -18,6 +18,13 @@ fn plain_text_composer_keeps_command_prefixes_literal() { ] { composer.handle_key_event(KeyEvent::new(KeyCode::Char(prefix), modifiers)); composer.handle_paste("keep this literal".into()); + if prefix == '/' { + let area = Rect::new(0, 0, /*width*/ 40, /*height*/ 4); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + assert_eq!(buffer[(0, 1)].symbol(), "›"); + assert_eq!(buffer[(2, 1)].symbol(), "/"); + } assert_eq!( composer.handle_key_event(KeyCode::Tab.into()).0, InputResult::Submitted { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs index 5db883b7e6..558a38a652 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs @@ -60,8 +60,7 @@ fn text(buffer: &Buffer) -> String { #[test] fn retained_command_suggestions_yield_paint_and_cursor_to_find() { let mut composer = composer(); - composer.draft.textarea.insert_str("/"); - composer.sync_popups(); + composer.insert_str("/"); let original = render(&composer, /*footer*/ None); let footer = TranscriptFooter { text: vec![ @@ -91,7 +90,8 @@ fn retained_command_suggestions_yield_paint_and_cursor_to_find() { .then_some((footer_area.x + 8, footer_area.y)) ); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); - assert_eq!(composer.draft.textarea.text(), "/"); + assert_eq!(composer.current_text(), "/"); + assert!(composer.draft.textarea.is_empty()); frames.push(format!( "{width}x{height}, cursor {cursor:?}\n{}", text(&buffer) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 312798f842..1be819fbc7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -431,7 +431,15 @@ impl ChatComposer { if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { return Vec::new(); } - Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query) + let prefix_len = self.draft.prompt_prefix.len(); + Self::case_insensitive_match_ranges(&self.current_text(), &search.query) + .into_iter() + .filter_map(|range| { + let start = range.start.saturating_sub(prefix_len); + let end = range.end.saturating_sub(prefix_len); + (start < end).then_some(start..end) + }) + .collect() } fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs index 2c9392fb91..bd0610df36 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs @@ -117,6 +117,22 @@ fn history_search_empty_paste_preserves_selected_match() { insta::assert_snapshot!("history_search_empty_paste", terminal.backend()); } +#[test] +fn history_search_highlights_absorbed_prompt_prefix_match() { + let mut composer = composer_with_history(); + composer + .history + .record_local_submission(HistoryEntry::new("/review changes".to_string())); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste("/review".to_string()); + + assert_eq!(composer.current_text(), "/review changes"); + assert_eq!(composer.history_search_highlight_ranges(), vec![0..6]); + + let terminal = render_composer(&composer, /*width*/ 70); + insta::assert_snapshot!("history_search_absorbed_slash", terminal.backend()); +} + #[test] fn history_search_paste_shows_separators_and_matches_original_query() { let mut composer = composer_with_history(); @@ -208,7 +224,7 @@ fn history_search_paste_uses_full_sanitized_text() { composer.handle_paste(pasted); assert_eq!(composer.history_search.as_ref().unwrap().query, query); - assert_eq!(composer.draft.textarea.text(), query); + assert_eq!(composer.current_text(), query); assert!(composer.attachments.local_image_paths().is_empty()); assert!(composer.draft.pending_pastes.is_empty()); assert!(composer.current_text_elements().is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs index 48f7adff59..7ad7202514 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -5,6 +5,7 @@ //! Command prefixes become escaped, editable prompt text before answers are appended. use super::super::textarea::VimPersistentState; +use super::draft_state::PromptPrefix; use super::*; impl ComposerDraft { @@ -32,13 +33,19 @@ impl ChatComposer { .swap_vim_persistent_state(&mut vim_state); let started_vim_edit = self.begin_direct_vim_edit(); // Recovered answers turn the draft into a prompt, so escape any command prefix - // before appending them. Shell mode stores its `!` outside the textarea. - let text = self.draft.textarea.text(); - let slash_command_draft = self.slash_input().should_parse_on_dequeue(text) + // before appending them. Command modes store their prefix outside the textarea. + let text = self.current_text(); + let slash_command_draft = self.slash_input().should_parse_on_dequeue(&text) && parse_slash_name(text.trim_start()).is_none_or(|(name, _, _)| !name.contains('/')); - if self.draft.is_bash_mode { - self.draft.is_bash_mode = false; - self.draft.textarea.insert_str_at(/*pos*/ 0, "\\!"); + if self.draft.prompt_prefix == PromptPrefix::Shell + || (self.draft.prompt_prefix == PromptPrefix::Slash && slash_command_draft) + { + self.unmark_slash_command_element(); + let prefix = self.draft.prompt_prefix.text(); + self.draft.prompt_prefix = PromptPrefix::None; + self.draft + .textarea + .insert_str_at(/*pos*/ 0, &format!("\\{prefix}")); } else if self.is_bang_shell_command() || slash_command_draft { let prefix_len = self.draft.textarea.text().len() - self.draft.textarea.text().trim_start().len(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs index dbd2fbd69e..6da43a77bc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse_tests.rs @@ -86,6 +86,31 @@ fn recovery_keeps_unselected_text_after_a_held_replace_key() { } } +#[test] +fn backspace_deletes_a_body_selection_before_an_absorbed_prefix() { + for prefix in ["!", "/"] { + let (mut composer, _rx) = new_test_composer(); + composer.insert_str(&format!("{prefix}review text")); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 4, + ); + composer.render(area, &mut Buffer::empty(area)); + let (x, y) = composer.cursor_pos(area).unwrap(); + for (kind, column) in [ + (Down(Left), x), + (MouseEventKind::Drag(Left), x - "review text".len() as u16), + (Up(Left), x - "review text".len() as u16), + ] { + mouse(&mut composer, area, kind, column, y); + } + + composer.handle_key_event(KeyCode::Backspace.into()); + + assert_eq!(composer.current_text(), prefix); + assert_eq!(composer.draft.textarea.mouse_selection_range(), None); + } +} + #[test] fn selected_text_navigation_does_not_recall_history_or_select_images() { for (key, remote) in [ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs index 78db041eb2..173678e904 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -28,7 +28,7 @@ impl ChatComposer { if let Some(elements_before) = elements_before { self.reconcile_deleted_elements(elements_before); } - self.sync_bash_mode_from_text(); + self.sync_prompt_prefix_from_text(); self.sync_popups(); if started_vim_edit { self.finish_vim_edit(); @@ -48,7 +48,7 @@ impl ChatComposer { FlushResult::Typed(ch) if self.draft.textarea.is_vim_replace_mode() => { textarea.set_cursor(snapshot.cursor); if let Some(selection) = self.draft.textarea.mouse_selection_range() { - let offset = usize::from(self.draft.is_bash_mode); + let offset = self.draft.prompt_prefix.len(); textarea.replace_range( selection.start + offset..selection.end + offset, &ch.to_string(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index 0cefc44b11..c7eed40764 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -5,6 +5,7 @@ //! Recovery commands must occupy one line and be visible before the submit key expands pastes. //! Offline draft edits also consume the Astra sparkle opportunity before rendering. +use super::draft_state::PromptPrefix; use super::*; #[derive(Clone, Copy, PartialEq, Eq)] @@ -17,12 +18,10 @@ impl ChatComposer { /// Preserve Enter inside a paste burst without attempting submission. pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool { let in_slash_context = self.slash_commands_enabled() - && !self.draft.is_bash_mode + && self.draft.prompt_prefix != PromptPrefix::Shell && (matches!(self.popups.active, ActivePopup::Command(_)) || self - .draft - .textarea - .text() + .current_text() .lines() .next() .unwrap_or("") @@ -83,10 +82,10 @@ impl ChatComposer { && self.submit_keys.is_pressed(key) { let input = self.slash_input(); - let text = self.draft.textarea.text(); + let text = self.current_text(); let command = input - .bare_command(text) - .or_else(|| input.inline_command(text).map(|command| command.command)) + .bare_command(&text) + .or_else(|| input.inline_command(&text).map(|command| command.command)) .filter(|_| text.trim().lines().count() == 1); if matches!(command, Some(SlashCommandItem::Builtin(command)) if command == SlashCommand::Warnings 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 index 0bd9c4d1cb..4ea71254c1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -24,6 +24,7 @@ use super::ActivePopup; use super::ChatComposer; use super::InputResult; use super::QueuedInputAction; +use super::draft_state::PromptPrefix; use super::parent_owned_command_is_allowed; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -225,11 +226,13 @@ impl ChatComposer { } pub fn set_daybreak_command_description(&mut self, description: Option<&'static str>) { - self.popups.daybreak_command_description = description; + self.update_slash_command_catalog(|composer| { + composer.popups.daybreak_command_description = description + }); } pub fn set_worktrees_enabled(&mut self, enabled: bool) { - self.worktrees_enabled = enabled; + self.update_slash_command_catalog(|composer| composer.worktrees_enabled = enabled); } /// Handle key event when the slash-command popup is visible. @@ -242,13 +245,17 @@ impl ChatComposer { } if key_event.code == KeyCode::Esc { // Always dismiss the popup without changing the draft. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); + let text = self.current_text(); + let first_line = text.lines().next().unwrap_or(""); self.popups.dismissed_command_token = command_popup_filter_text(first_line, /*cursor*/ 0); self.popups.active = ActivePopup::None; return (InputResult::None, true); } self.footer.mode = reset_mode_after_activity(self.footer.mode); + let current_text = self.current_text(); + let current_cursor = self.current_cursor(); + let first_line = current_text.lines().next().unwrap_or(""); let ActivePopup::Command(popup) = &mut self.popups.active else { unreachable!(); }; @@ -282,11 +289,8 @@ impl ChatComposer { } => { // 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()); + let filter_text = command_popup_filter_text(first_line, current_cursor) + .unwrap_or_else(|| first_line.to_owned()); popup.on_composer_text_change(filter_text); let has_visible_items = !popup.filtered_items().is_empty(); let selected_item = popup.selected_item(); @@ -297,7 +301,7 @@ impl ChatComposer { { self.stage_selected_slash_command_history(&selected_cmd); self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; return (InputResult::Command(*cmd), true); } @@ -310,11 +314,9 @@ impl ChatComposer { } if let Some(completed_text) = - selected_command_completion(&first_line, &selected_cmd) + selected_command_completion(first_line, &selected_cmd) { - self.draft - .textarea - .set_text_clearing_elements(&completed_text); + self.set_canonical_text_clearing_elements(completed_text); if !self.draft.textarea.text().is_empty() { self.draft .textarea @@ -338,11 +340,8 @@ impl ChatComposer { } => { // 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()); + let filter_text = command_popup_filter_text(first_line, current_cursor) + .unwrap_or_else(|| first_line.to_owned()); popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { if self @@ -354,12 +353,9 @@ impl ChatComposer { } if let Some(completed_text) = - selected_command_completion(&first_line, &selected_cmd) + selected_command_completion(first_line, &selected_cmd) { - self.draft - .textarea - .set_text_clearing_elements(&completed_text); - self.draft.is_bash_mode = false; + self.set_canonical_text_clearing_elements(completed_text); } if !self.draft.textarea.text().is_empty() { self.draft @@ -378,11 +374,10 @@ impl ChatComposer { if let Some(sel) = popup.selected_item() { if self.blocks_direct_input { let command_is_allowed = match &sel { - CommandItem::Builtin(cmd) => { - parse_slash_name(self.draft.textarea.text()).is_some_and( - |(_, args, _)| parent_owned_command_is_allowed(*cmd, args), - ) - } + CommandItem::Builtin(cmd) => parse_slash_name(¤t_text) + .is_some_and(|(_, args, _)| { + parent_owned_command_is_allowed(*cmd, args) + }), CommandItem::ServiceTier(_) => false, }; if !command_is_allowed { @@ -402,7 +397,7 @@ impl ChatComposer { if !matches!(sel, CommandItem::Builtin(cmd) if cmd.requires_dispatch_validation()) { self.draft.textarea.set_text_clearing_elements(""); - self.draft.is_bash_mode = false; + self.draft.prompt_prefix = PromptPrefix::None; } return ( match sel { @@ -432,9 +427,9 @@ impl ChatComposer { return false; } - let text = self.draft.textarea.text(); + let text = self.current_text(); let first_line_end = text.find('\n').unwrap_or(text.len()); - let cursor = self.draft.textarea.cursor(); + let cursor = self.current_cursor(); if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) { return false; } @@ -463,6 +458,8 @@ impl ChatComposer { format!("{selected_command_text} ") }; + let prefix_len = self.draft.prompt_prefix.len(); + let internal_replace_end = replace_end.saturating_sub(prefix_len); let ranges_to_unmark = self .draft .textarea @@ -470,7 +467,8 @@ impl ChatComposer { .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) + (range.start < internal_replace_end && internal_replace_end < range.end) + .then_some(range) }) .collect::>(); for range in ranges_to_unmark { @@ -478,8 +476,8 @@ impl ChatComposer { } self.draft .textarea - .replace_range(0..replace_end, &replacement); - self.draft.is_bash_mode = false; + .replace_range(0..internal_replace_end, &replacement[prefix_len..]); + self.draft.prompt_prefix = PromptPrefix::Slash; self.draft .textarea .set_cursor(self.draft.textarea.text().len()); @@ -488,41 +486,27 @@ impl ChatComposer { /// Keep slash command elements aligned with the current first line. pub(super) fn sync_slash_command_elements(&mut self) { + self.unmark_slash_command_element(); 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); - } + let canonical_text = self.current_text(); + let first_line_end = canonical_text.find('\n').unwrap_or(canonical_text.len()); + let first_line = &canonical_text[..first_line_end]; + let cursor = self.current_cursor(); + let prefix_len = self.draft.prompt_prefix.len(); + let desired_range = self + .slash_input() + .command_element_range(first_line, cursor) + .map(|range| 0..range.end.saturating_sub(prefix_len)); + if let Some(range) = desired_range { + self.draft.slash_command_element_id = self.draft.textarea.add_element_range(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 unmark_slash_command_element(&mut self) { + if let Some(id) = self.draft.slash_command_element_id.take() { + self.draft.textarea.remove_element(id); } } } @@ -636,8 +620,8 @@ mod tests { 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.set_text_content(text.to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(cursor); composer.sync_popups(); composer } @@ -650,8 +634,8 @@ mod tests { fn unavailable_side_conversation_command_still_opens_popup() { let mut composer = test_composer(); composer.set_side_conversation_active(/*active*/ true); - composer.draft.textarea.set_text_clearing_elements("/arch"); - composer.draft.textarea.set_cursor(/*pos*/ 5); + composer.set_text_content("/arch".to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(/*cursor*/ 5); composer.sync_popups(); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); @@ -661,12 +645,12 @@ mod tests { fn side_conversation_tab_completes_available_match_after_unavailable_rows() { let mut composer = test_composer(); composer.set_side_conversation_active(/*active*/ true); - composer.draft.textarea.set_text_clearing_elements("/a"); - composer.draft.textarea.set_cursor(/*pos*/ 2); + composer.set_text_content("/a".to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(/*cursor*/ 2); composer.sync_popups(); assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/agents "); + assert_eq!(composer.current_text(), "/agents "); } #[test] @@ -674,12 +658,12 @@ mod tests { let mut composer = test_composer(); composer.set_side_conversation_active(/*active*/ true); composer.set_task_running(/*running*/ true); - composer.draft.textarea.set_text_clearing_elements("/arch"); - composer.draft.textarea.set_cursor(/*pos*/ 5); + composer.set_text_content("/arch".to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(/*cursor*/ 5); composer.sync_popups(); assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/arch"); + assert_eq!(composer.current_text(), "/arch"); } #[test] @@ -687,11 +671,8 @@ mod tests { let mut composer = test_composer(); composer.set_side_conversation_active(/*active*/ true); composer.set_task_running(/*running*/ true); - composer - .draft - .textarea - .set_text_clearing_elements("/agents"); - composer.draft.textarea.set_cursor(/*pos*/ 7); + composer.set_text_content("/agents".to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(/*cursor*/ 7); composer.sync_popups(); assert_eq!( @@ -711,12 +692,12 @@ mod tests { let mut composer = test_composer(); composer.set_side_conversation_active(/*active*/ true); composer.set_disable_paste_burst(/*disabled*/ true); - composer.draft.textarea.set_text_clearing_elements("/new"); - composer.draft.textarea.set_cursor(/*pos*/ 4); + composer.set_text_content("/new".to_string(), Vec::new(), Vec::new()); + composer.set_current_cursor(/*cursor*/ 4); composer.sync_popups(); assert_eq!(press(&mut composer, KeyCode::Char('/')), InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/new/"); + assert_eq!(composer.current_text(), "/new/"); } #[test] @@ -727,7 +708,7 @@ mod tests { assert_eq!(press(&mut composer, KeyCode::Esc), InputResult::None); assert!(!composer.popup_active()); - assert_eq!(composer.draft.textarea.text(), "/rev"); + assert_eq!(composer.current_text(), "/rev"); } #[test] @@ -737,15 +718,15 @@ mod tests { 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()); + assert_eq!(composer.current_text(), expected_text); + assert_eq!(composer.current_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); + assert_eq!(composer.current_text(), expected_text); } #[test] @@ -756,21 +737,21 @@ mod tests { ); assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/status "); - assert_eq!(composer.draft.textarea.cursor(), "/status ".len()); + assert_eq!(composer.current_text(), "/status "); + assert_eq!(composer.current_cursor(), "/status ".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 "); + assert_eq!(composer.current_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_eq!(composer.draft.textarea.text(), "/review "); + assert_eq!(composer.current_text(), "/review "); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap index b0436bc381..7943f73ba4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap @@ -1,11 +1,11 @@ --- source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs -assertion_line: 231 +assertion_line: 100 expression: "frames.join(\"\\n\\n\")" --- 60x8, cursor Some((10, 6)) -› / +/ @@ -15,7 +15,7 @@ expression: "frames.join(\"\\n\\n\")" 24x8, cursor Some((10, 6)) -› / +/ @@ -25,4 +25,4 @@ expression: "frames.join(\"\\n\\n\")" 12x3, cursor None -› / +/ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_absorbed_slash.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_absorbed_slash.snap new file mode 100644 index 0000000000..ed1406f290 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_absorbed_slash.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +expression: terminal.backend() +--- +" " +"/ review changes " +" " +" " +" reverse-i-search: /review enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap index 6697092d6c..b60d0594be 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap @@ -7,7 +7,7 @@ command during startup: › /status show current session configuration and token usage /statusline configure which items appear in the status line -› /status +/ status 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap index bb446cf9e1..9b357a4402 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap @@ -11,7 +11,7 @@ Buffer { " /mention mention a file ", " /mcp list MCP tools; use /mcp verbose or /mcp login ", " ", - "› /m ", + "/ m ", " ", " model · high · fast ", ], diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs index bb14fbe533..c1b0787420 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs @@ -26,6 +26,7 @@ use unicode_width::UnicodeWidthStr; use super::ChatComposer; use super::FooterMode; use super::InputResult; +use super::draft_state::PromptPrefix; use crate::bottom_pane::BottomPane; use crate::terminal_palette::StdoutColorLevel; use crate::terminal_palette::default_fg; @@ -315,7 +316,9 @@ impl ChatComposer { let Some(body) = text.strip_prefix('/') else { return false; }; - if !self.slash_commands_enabled() || self.draft.is_bash_mode || text.contains(['\n', '\r']) + if !self.slash_commands_enabled() + || self.draft.prompt_prefix == PromptPrefix::Shell + || text.contains(['\n', '\r']) { return false; } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 03e761f4f0..8e650b7670 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -392,7 +392,7 @@ fn slash_popup_preserves_footer_and_selection_style() { } = composer.layout_with_options(area, ComposerRenderOptions::default()); assert_eq!(popup.bottom(), surface.y); assert_eq!(surface.bottom(), footer.y); - assert_eq!(composer.cursor_pos(area), Some((4, textarea.y))); + assert_eq!(composer.cursor_pos(area), Some((3, textarea.y))); let footer_text: String = (0..width).map(|x| buf[(x, height - 1)].symbol()).collect(); assert!(footer_text.contains("model · high · fast")); if !clipped && width == 80 { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs index 6ab2ea930b..8428b0686a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs @@ -15,6 +15,7 @@ use super::super::ChatComposer; use super::super::InputResult; use super::super::LARGE_PASTE_CHAR_THRESHOLD; use super::super::PasteBurst; +use super::super::draft_state::PromptPrefix; use super::super::tests::new_test_composer; use super::super::tests::snapshot_composer_state_with_width; use crate::keymap::RuntimeKeymap; @@ -159,16 +160,19 @@ fn recovered_answers_undo_as_one_vim_edit() { ctrl_r(&mut composer); assert_eq!(composer.draft_snapshot(), after); } - for command in ["!echo", "/diff"] { + for command in ["!echo", "/diff", "/review "] { let mut composer = vim_composer(command); + composer.move_cursor_to_end(); + composer.sync_popups(); let before = composer.draft_snapshot(); composer.append_recovered_drafts("answer"); assert_eq!(composer.current_text(), format!("\\{command}\n\nanswer")); + assert!(composer.draft.textarea.text_elements().is_empty()); keys(&mut composer, "u"); assert_eq!(composer.draft_snapshot(), before); } let mut composer = vim_composer("echo"); - composer.draft.is_bash_mode = true; + composer.draft.prompt_prefix = PromptPrefix::Shell; let before = composer.draft_snapshot(); composer.append_recovered_drafts("answer"); assert_eq!(composer.current_text(), "\\!echo\n\nanswer"); @@ -672,7 +676,7 @@ fn vim_replace_backspace_restores_characters_in_new_slash_element() { composer.set_vim_enabled(/*enabled*/ true); composer.draft.textarea.set_cursor(/*pos*/ 0); keys(composer, "R/diff"); - assert_eq!(composer.draft.textarea.element_payloads(), vec!["/diff"]); + assert_eq!(composer.draft.textarea.element_payloads(), vec!["diff"]); for expected in [("/dife tail", 4), ("/dide tail", 3)] { let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); assert_eq!( @@ -684,6 +688,34 @@ fn vim_replace_backspace_restores_characters_in_new_slash_element() { ); } +#[test] +fn vim_replace_delete_backward_restores_characters_hidden_by_prompt_prefixes() { + for (original, replacement, delete_backward, repeat) in [ + ( + "abcde tail", + "R/diff", + KeyEvent::from(KeyCode::Backspace), + 5, + ), + ( + "ébc", + "R!", + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), + 1, + ), + ] { + let mut composer = vim_composer(original); + keys(&mut composer, replacement); + for _ in 0..repeat { + let _ = composer.handle_key_event(delete_backward); + } + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (original.to_owned(), 0) + ); + } +} + #[test] fn vim_editing_modes_use_the_same_large_and_image_paste_handling() { let temporary = tempfile::tempdir().expect("create image directory"); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap index e92755f38e..04294ccb9d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap @@ -3,5 +3,5 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› /mo " +"/ mo " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap index 2ad183893b..42c2791ee3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_normal_slash.snap @@ -8,6 +8,6 @@ expression: terminal.backend() " commands " " /ide include current selection, open files, and" " " -"› / " +"/ " " " " 100% context left | Vim: Insert " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap index d248b7de58..0053744e91 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap @@ -5,7 +5,7 @@ expression: terminal.backend() --- "› /diff show git diff (including untracked files) " " " -"› /dide tail " +"/ dide tail " " " " " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap index 2eae5af3d3..b4834dcb95 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap @@ -5,6 +5,6 @@ expression: "render_snapshot(&pane, area)" • Working (0s • esc to interrupt) -› /rev +/ rev tab to queue message 100% context left diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 7a2540d993..13cd890e73 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -793,7 +793,7 @@ impl TextArea { } if self.is_vim_replace_mode() && self.mouse_selection_range().is_none() - && self.editor_keymap.delete_backward.is_pressed(event) + && self.is_delete_backward_key(event) && self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter) { return; @@ -802,6 +802,10 @@ impl TextArea { self.input_with_keymap(event, &keymap); } + pub(in crate::bottom_pane) fn is_delete_backward_key(&self, event: KeyEvent) -> bool { + self.editor_keymap.delete_backward.is_pressed(event) + } + fn leave_vim_insert_mode(&mut self) { self.mouse_selection = None; let bol = self.beginning_of_current_line(); @@ -1680,6 +1684,13 @@ impl TextArea { .map(|element| element.id) } + pub(crate) fn element_range(&self, id: u64) -> Option> { + self.elements + .iter() + .find(|element| element.id == id) + .map(|element| element.range.clone()) + } + /// Renames a single text element in-place, keeping it atomic. /// /// Use this when the element payload is an identifier (e.g. a placeholder) that must be @@ -1799,6 +1810,10 @@ impl TextArea { len_before != self.elements.len() } + pub(crate) fn remove_element(&mut self, id: u64) { + self.elements.retain(|element| element.id != id); + } + fn next_element_id(&mut self) -> u64 { let id = self.next_element_id; self.next_element_id = self.next_element_id.saturating_add(1); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs index 9e798f84ac..f68c60f24f 100644 --- a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -119,6 +119,22 @@ impl TextArea { self.vim_commands.replace_steps.clear(); } + /// Remove a newly typed prompt marker without losing its Vim Replace recovery step. + pub(in crate::bottom_pane) fn absorb_prompt_prefix(&mut self, prefix_len: usize) { + if self.is_vim_replace_mode() + && self.cursor_pos == prefix_len + && let Some(step) = self.vim_commands.replace_steps.last_mut() + && step.cursor_before == 0 + && step.start == 0 + && step.inserted_len == prefix_len + { + step.inserted_len = 0; + self.replace_range_preserving_recovery(0..prefix_len, ""); + } else { + self.replace_range(0..prefix_len, ""); + } + } + pub(in crate::bottom_pane) fn replace_vim_text(&mut self, text: &str) { for grapheme in text.graphemes(/*is_extended*/ true) { let cursor_before = self.cursor_pos; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_sol61_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_sol61_ultrafast.snap index bc71ac9a71..a16c4bdc77 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_sol61_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_sol61_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6.1 Sol default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6.1-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_ultrafast.snap index e0809c1682..88ecdefff0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_mantle_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6-Astra default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6-Astra default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_sol61_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_sol61_ultrafast.snap index bade4689a7..77f4d40cad 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_sol61_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_sol61_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6.1 Sol (Global) default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6.1-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_ultrafast.snap index 0a2120ba85..8e879e6eb9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_global_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6-Astra (Global) default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6-Astra default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_sol61_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_sol61_ultrafast.snap index 900ed4f307..21eb1f3c4b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_sol61_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_sol61_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6.1 Sol (US cross-region) default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6.1-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_ultrafast.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_ultrafast.snap index f08ee83308..5eb6199c48 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_ultrafast.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__bedrock_runtime_us_ultrafast.snap @@ -5,13 +5,13 @@ expression: "format!(\"Bundled catalog:\\n{bundled_view}\\n\\nCustom catalog:\\n Bundled catalog: › /ultrafast The fastest available responses for latency-sensitive work. -› /ultrafast +/ ultrafast GPT-6-Astra (US cross-region) default · /tmp/project Custom catalog: › /express My custom Bedrock tier. -› /express +/ express GPT-6-Astra default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap index d6477eeace..98b55ab5cc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_picker_refreshes_service_tier_controls.snap @@ -2,6 +2,6 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: "render_bottom_popup(&chat, 80)" --- -› /fast +/ fast gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap index d42eb2f2bb..933b189d35 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap @@ -4,6 +4,6 @@ expression: popup --- › /worktree start or continue a conversation in a new worktree -› /work +/ work GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_remote.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_remote.snap index 91076caee0..b6d294eecf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_remote.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_remote.snap @@ -3,6 +3,6 @@ source: tui/src/chatwidget/tests/slash_commands.rs assertion_line: 3091 expression: popup --- -› /work +/ work GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap index 6505ef3a24..c10befa7da 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_allowed.snap @@ -4,6 +4,6 @@ expression: "render_bottom_popup(&chat, 80)" --- › /ultrafast ultrafast processing -› /ultra +/ ultra GPT-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap index 25210e0ac9..83a81d1d9b 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__ultrafast_command_denied.snap @@ -2,6 +2,6 @@ source: tui/src/chatwidget/tests/slash_commands.rs expression: "render_bottom_popup(&chat, 80)" --- -› /ultra +/ ultra GPT-5.5 default · /tmp/project diff --git a/codex-rs/tui/tests/suite/worktree_stack.rs b/codex-rs/tui/tests/suite/worktree_stack.rs index ee28ab173d..218444c471 100644 --- a/codex-rs/tui/tests/suite/worktree_stack.rs +++ b/codex-rs/tui/tests/suite/worktree_stack.rs @@ -184,7 +184,7 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul fn submit(terminal: &mut PtyCodex, input: &str) -> Result<()> { terminal.write_input(input.as_bytes())?; - terminal.wait_for_screen(input)?; + terminal.wait_for_screen(&input.replacen('/', "/ ", 1))?; // Bulk PTY writes resemble a paste; wait out the Enter suppression window. std::thread::sleep(Duration::from_millis(/*millis*/ 250)); terminal.write_input(b"\r")?;