diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2176e7762ec6..ef870c67760c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7699,6 +7699,114 @@ mod tests { } } + #[test] + fn slash_popup_btw_for_bt_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + 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*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_bt", terminal.backend()); + } + + #[test] + fn slash_popup_btw_for_bt_logic() { + use super::super::command_popup::CommandItem; + 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*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'b', 't']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "btw") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected btw command, got service tier {command:?}") + } + None => panic!("no selected command for '/bt'"), + }, + _ => panic!("slash popup not active after typing '/bt'"), + } + } + + #[test] + fn slash_popup_side_for_si_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + 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*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_si", terminal.backend()); + } + + #[test] + fn slash_popup_side_for_si_logic() { + use super::super::command_popup::CommandItem; + 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*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 's', 'i']); + + match &composer.popups.active { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "side") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected side command, got service tier {command:?}") + } + None => panic!("no selected command for '/si'"), + }, + _ => panic!("slash popup not active after typing '/si'"), + } + } + #[test] fn service_tier_slash_command_dispatches_from_catalog_name() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 080f320f5ec3..f6e6b4929c6e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -18,8 +18,9 @@ use crate::render::RectExt; use crate::slash_command::SlashCommand; // Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit]; +// `quit` is an alias of `exit`, and `btw` is an alias of `side`, so we skip +// those aliases here. +const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Btw]; const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ColumnWidthMode::AutoAllRows, /*name_column_width*/ None, @@ -418,6 +419,18 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn btw_hidden_in_empty_filter_but_shown_for_prefix() { + let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); + popup.on_composer_text_change("/".to_string()); + let items = popup.filtered_items(); + assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + + popup.on_composer_text_change("/bt".to_string()); + let items = popup.filtered_items(); + assert!(items.contains(&CommandItem::Builtin(SlashCommand::Btw))); + } + #[test] fn plan_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap new file mode 100644 index 000000000000..5aee8ff4d89b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /bt " +" " +" " +" /btw start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap new file mode 100644 index 000000000000..50a89ae1abc9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /si " +" " +" " +" /side start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9d8d49a77cf0..dcf04868223a 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -30,8 +30,6 @@ struct PreparedSlashCommandArgs { } const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; -const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = - "'/side' is unavailable while code review is running."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE: &str = "Usage: /goal "; @@ -114,9 +112,12 @@ impl ChatWidget { }); } - fn request_empty_side_conversation(&mut self) { + fn request_empty_side_conversation(&mut self, cmd: SlashCommand) { let Some(parent_thread_id) = self.thread_id else { - self.add_error_message("'/side' is unavailable before the session starts.".to_string()); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable before the session starts." + )); return; }; @@ -239,8 +240,8 @@ impl ChatWidget { ); } } - SlashCommand::Side => { - self.request_empty_side_conversation(); + SlashCommand::Side | SlashCommand::Btw => { + self.request_empty_side_conversation(cmd); } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); @@ -745,11 +746,12 @@ impl ChatWidget { self.bottom_pane.drain_pending_submission_state(); } } - SlashCommand::Side if !trimmed.is_empty() => { + SlashCommand::Side | SlashCommand::Btw if !trimmed.is_empty() => { let Some(parent_thread_id) = self.thread_id else { - self.add_error_message( - "'/side' is unavailable before the session starts.".to_string(), - ); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable before the session starts." + )); return; }; let user_message = self.prepared_inline_user_message( @@ -957,6 +959,7 @@ impl ChatWidget { | SlashCommand::Plan | SlashCommand::Goal | SlashCommand::Side + | SlashCommand::Btw | SlashCommand::Keymap | SlashCommand::Agent | SlashCommand::MultiAgents @@ -1017,11 +1020,14 @@ impl ChatWidget { } fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool { - if cmd != SlashCommand::Side || !self.review.is_review_mode { + if !matches!(cmd, SlashCommand::Side | SlashCommand::Btw) || !self.review.is_review_mode { return true; } - self.add_error_message(SIDE_REVIEW_UNAVAILABLE_MESSAGE.to_string()); + let command = cmd.command(); + self.add_error_message(format!( + "'/{command}' is unavailable while code review is running." + )); self.bottom_pane.drain_pending_submission_state(); false } diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 906deb3b566d..39a0cb9d7337 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -173,6 +173,59 @@ async fn slash_side_is_rejected_during_review_mode() { ); } +#[tokio::test] +async fn slash_btw_is_rejected_during_review_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.review.is_review_mode = true; + + chat.dispatch_command(SlashCommand::Btw); + + let event = rx + .try_recv() + .expect("expected review-mode btw conversation error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/btw' is unavailable while code review is running."), + "expected review-mode btw conversation error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert!( + op_rx.try_recv().is_err(), + "expected no side conversation op" + ); +} + +#[tokio::test] +async fn slash_btw_is_rejected_before_the_session_starts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Btw); + + let event = rx + .try_recv() + .expect("expected pre-session btw conversation error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/btw' is unavailable before the session starts."), + "expected pre-session btw conversation error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert!( + op_rx.try_recv().is_err(), + "expected no side conversation op" + ); +} + #[tokio::test] async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -219,6 +272,31 @@ async fn slash_side_without_args_starts_empty_side_conversation() { assert!(chat.input_queue.queued_user_messages.is_empty()); } +#[tokio::test] +async fn slash_btw_without_args_starts_empty_side_conversation() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let parent_thread_id = ThreadId::new(); + chat.thread_id = Some(parent_thread_id); + chat.on_task_started(); + chat.bottom_pane + .set_composer_text("/btw".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::StartSide { + parent_thread_id: emitted_parent_thread_id, + user_message: None, + }) if emitted_parent_thread_id == parent_thread_id + ); + assert!( + op_rx.try_recv().is_err(), + "bare /btw should not submit an op on the parent thread" + ); + assert!(chat.input_queue.queued_user_messages.is_empty()); +} + #[tokio::test] async fn slash_side_requests_forked_side_question_while_task_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -268,6 +346,41 @@ async fn slash_side_requests_forked_side_question_while_task_running() { ); } +#[tokio::test] +async fn slash_btw_requests_forked_side_question_while_task_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let parent_thread_id = ThreadId::new(); + chat.thread_id = Some(parent_thread_id); + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/btw explore the codebase".to_string(), + Vec::new(), + Vec::new(), + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::StartSide { + parent_thread_id: emitted_parent_thread_id, + user_message: Some(user_message), + }) if emitted_parent_thread_id == parent_thread_id + && user_message + == UserMessage { + text: "explore the codebase".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + } + ); + assert!( + op_rx.try_recv().is_err(), + "expected no op on the parent thread" + ); +} + #[tokio::test] async fn side_context_label_preserves_status_line_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 970d1cbd5aa5..4a43409296a7 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,6 +38,7 @@ pub enum SlashCommand { Goal, Agent, Side, + Btw, Copy, Raw, Diff, @@ -114,7 +115,9 @@ impl SlashCommand { SlashCommand::Plan => "switch to Plan mode", SlashCommand::Goal => "set or view the goal for a long-running task", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Side => "start a side conversation in an ephemeral fork", + SlashCommand::Side | SlashCommand::Btw => { + "start a side conversation in an ephemeral fork" + } SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::Keymap => "remap TUI shortcuts", SlashCommand::Vim => "toggle Vim mode for the composer", @@ -154,6 +157,7 @@ impl SlashCommand { | SlashCommand::Raw | SlashCommand::Pets | SlashCommand::Side + | SlashCommand::Btw | SlashCommand::Resume | SlashCommand::SandboxReadRoot ) @@ -217,7 +221,8 @@ impl SlashCommand { | SlashCommand::Ide | SlashCommand::Quit | SlashCommand::Exit - | SlashCommand::Side => true, + | SlashCommand::Side + | SlashCommand::Btw => true, SlashCommand::Rollout => true, SlashCommand::TestApproval => true, SlashCommand::Realtime => true,