mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Add btw alias for side slash command (#23592)
This commit is contained in:
committed by
GitHub
Unverified
parent
e9f59e30d9
commit
f198ca115b
@@ -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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
|
||||
@@ -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());
|
||||
|
||||
+9
@@ -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 "
|
||||
+9
@@ -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 "
|
||||
@@ -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 <objective>";
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user