From 5009a99dfc07830ddf7464c26f310a2b8532b55d Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 16 Oct 2026 23:56:24 +0000 Subject: [PATCH] Render slash command prefixes as composer prompt markers (#54742) ## What changed - Display the leading `/` in the prompt marker instead of the editable text, sharing prefix state with shell mode's `!`. - Preserve command prefixes and text element offsets across submission, queuing, history, and external editing. Keep prefixes literal in plain-text composers. - Track composer-owned slash command elements separately, omit them from exported draft elements, and refresh them when command availability changes. - Let backward deletion at the start of the text remove the prompt prefix while preserving selected-text deletion and Vim Replace recovery. ## Testing Add regression coverage for slash prompt cursor placement, prefix deletion, history search highlighting, dynamic command availability, and Vim Replace restoration, including Unicode text. Update rendering snapshots for the slash prompt marker. GitOrigin-RevId: 0da42da8e98bf7885f9b0ba2c8d5ad4f44d6847e --- .../tui/src/app/owned_transcript_tests.rs | 5 +- ..._tests__slash_picker_overlays_history.snap | 2 +- .../app/tests/unavailable_commands_tests.rs | 6 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 396 ++++++++++++------ .../bottom_pane/chat_composer/draft_state.rs | 28 +- .../chat_composer/embedded_input_tests.rs | 7 + .../chat_composer/footer_state_tests.rs | 6 +- .../chat_composer/history_search.rs | 10 +- .../history_search_paste_tests.rs | 18 +- .../bottom_pane/chat_composer/inline_input.rs | 19 +- .../bottom_pane/chat_composer/mouse_tests.rs | 25 ++ .../bottom_pane/chat_composer/paste_input.rs | 4 +- .../bottom_pane/chat_composer/reconnect.rs | 13 +- .../bottom_pane/chat_composer/slash_input.rs | 161 ++++--- ...nd_hides_retained_command_suggestions.snap | 8 +- ..._tests__history_search_absorbed_slash.snap | 9 + ...aits_for_the_pending_command_to_leave.snap | 2 +- ...rface__tests__slash_popup_footer_wide.snap | 2 +- .../src/bottom_pane/chat_composer/sparkle.rs | 5 +- .../chat_composer/status_surface_tests.rs | 2 +- .../chat_composer/vim_history_tests.rs | 38 +- ...mposer__tests__slash_popup_mo_clipped.snap | 2 +- ...mposer__tests__vim_empty_normal_slash.snap | 2 +- ...er__tests__vim_replace_slash_recovery.snap | 2 +- ..._tests__slash_command_popup_dismissed.snap | 2 +- codex-rs/tui/src/bottom_pane/textarea.rs | 17 +- .../src/bottom_pane/textarea/vim_commands.rs | 16 + ...tests__bedrock_mantle_sol61_ultrafast.snap | 4 +- ...dget__tests__bedrock_mantle_ultrafast.snap | 4 +- ...edrock_runtime_global_sol61_ultrafast.snap | 4 +- ...sts__bedrock_runtime_global_ultrafast.snap | 4 +- ...s__bedrock_runtime_us_sol61_ultrafast.snap | 4 +- ...__tests__bedrock_runtime_us_ultrafast.snap | 4 +- ...icker_refreshes_service_tier_controls.snap | 2 +- ...idget__tests__worktrees_command_local.snap | 2 +- ...dget__tests__worktrees_command_remote.snap | 2 +- ...h_commands__ultrafast_command_allowed.snap | 2 +- ...sh_commands__ultrafast_command_denied.snap | 2 +- codex-rs/tui/tests/suite/worktree_stack.rs | 2 +- 39 files changed, 575 insertions(+), 268 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_absorbed_slash.snap 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")?;