Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down
17 changes: 15 additions & 2 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 12 deletions codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>";
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -957,6 +959,7 @@ impl ChatWidget {
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Keymap
| SlashCommand::Agent
| SlashCommand::MultiAgents
Expand Down Expand Up @@ -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
}
Expand Down
113 changes: 113 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/side.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading