tui: queue follow-ups during manual /compact (#15259)

## Summary
- queue input after the user submits `/compact` until that manual
compact turn ends
- mirror the same behavior in the app-server TUI
- add regressions for input queued before compact starts and while it is
running

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charley CunninghamandCodex authored and GitHub committed 2026-03-23 10:19:44 -07:00
1 parent 54801634e1
commit e838645fa2
43 files changed
+1898 -157

No files matched your search

+61 -7
View File
@@ -48,7 +48,9 @@ use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -63,6 +65,7 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -967,6 +970,18 @@ fn normalize_harness_overrides_for_cwd(
Ok(overrides)
}
fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<AppServerTurnError> {
let TypedRequestError::Server { source, .. } = error else {
return None;
};
let turn_error: AppServerTurnError = serde_json::from_value(source.data.clone()?).ok()?;
matches!(
turn_error.codex_error_info,
Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. })
)
.then_some(turn_error)
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -1950,9 +1965,21 @@ impl App {
personality,
} => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server
match app_server
.turn_steer(thread_id, turn_id, items.to_vec())
.await?;
.await
{
Ok(_) => {}
Err(error) => {
if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) {
if !self.chat_widget.enqueue_rejected_steer() {
self.chat_widget.add_error_message(turn_error.message);
}
} else {
return Err(error.into());
}
}
}
} else {
app_server
.turn_start(
@@ -5170,10 +5197,12 @@ mod tests {
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -5186,6 +5215,7 @@ mod tests {
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as AppServerUserInput;
@@ -5735,7 +5765,7 @@ mod tests {
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -5785,7 +5815,7 @@ mod tests {
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -5834,7 +5864,7 @@ mod tests {
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -5881,7 +5911,7 @@ mod tests {
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -5951,7 +5981,7 @@ mod tests {
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
@@ -8017,6 +8047,30 @@ guardian_approval = true
);
}
#[test]
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
let turn_error = AppServerTurnError {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: AppServerNonSteerableTurnKind::Review,
}),
additional_details: None,
};
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: turn_error.message.clone(),
data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")),
},
};
assert_eq!(
active_turn_not_steerable_turn_error(&error),
Some(turn_error)
);
}
#[test]
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
let mut presets = all_model_presets();
@@ -1,6 +1,7 @@
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
@@ -429,7 +430,7 @@ impl AppServerSession {
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> Result<TurnSteerResponse> {
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnSteer {
@@ -441,7 +442,6 @@ impl AppServerSession {
},
})
.await
.wrap_err("turn/steer failed in app-server TUI")
}
pub(crate) async fn thread_set_name(
+19 -4
View File
@@ -821,8 +821,10 @@ impl BottomPane {
&mut self,
queued: Vec<String>,
pending_steers: Vec<String>,
rejected_steers: Vec<String>,
) {
self.pending_input_preview.pending_steers = pending_steers;
self.pending_input_preview.rejected_steers = rejected_steers;
self.pending_input_preview.queued_messages = queued;
self.request_redraw();
}
@@ -1143,7 +1145,8 @@ impl BottomPane {
}
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
let has_pending_input = !self.pending_input_preview.queued_messages.is_empty()
|| !self.pending_input_preview.pending_steers.is_empty();
|| !self.pending_input_preview.pending_steers.is_empty()
|| !self.pending_input_preview.rejected_steers.is_empty();
let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
@@ -1546,7 +1549,11 @@ mod tests {
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
let width = 48;
let height = pane.desired_height(width);
@@ -1573,7 +1580,11 @@ mod tests {
});
pane.set_task_running(true);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
pane.hide_status_indicator();
let width = 48;
@@ -1601,7 +1612,11 @@ mod tests {
});
pane.set_task_running(true);
pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new());
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
Vec::new(),
);
let width = 48;
let height = pane.desired_height(width);
@@ -10,17 +10,19 @@ use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
/// Widget that displays pending steers plus follow-up messages held while a turn is in progress.
///
/// The widget renders pending steers first, then queued user messages, as two
/// labeled sections. Pending steers explain that they will be submitted after
/// the next tool/result boundary unless the user presses Esc to interrupt and
/// send them immediately. The edit hint at the bottom only appears when there
/// are actual queued user messages to pop back into the composer. Because some
/// terminals intercept certain modifier-key combinations, the displayed
/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
/// The widget renders pending steers first, then rejected steers that will be
/// resubmitted at end of turn, then ordinary queued user messages. Pending
/// steers explain that they will be submitted after the next tool/result
/// boundary unless the user presses Esc to interrupt and send them
/// immediately. The edit hint at the bottom only appears when there are actual
/// queued user messages to pop back into the composer. Because some terminals
/// intercept certain modifier-key combinations, the displayed binding is
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct PendingInputPreview {
pub pending_steers: Vec<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable.
@@ -33,6 +35,7 @@ impl PendingInputPreview {
pub(crate) fn new() -> Self {
Self {
pending_steers: Vec::new(),
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: key_hint::alt(KeyCode::Up),
}
@@ -67,7 +70,11 @@ impl PendingInputPreview {
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
if (self.pending_steers.is_empty()
&& self.rejected_steers.is_empty()
&& self.queued_messages.is_empty())
|| width < 4
{
return Box::new(());
}
@@ -96,6 +103,27 @@ impl PendingInputPreview {
}
}
if !self.rejected_steers.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
Self::push_section_header(
&mut lines,
width,
"Messages to be submitted at end of turn".into(),
);
for steer in &self.rejected_steers {
let wrapped = adaptive_wrap_lines(
steer.lines().map(|line| Line::from(line.dim())),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ↳ ".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
}
}
if !self.queued_messages.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
@@ -304,6 +332,9 @@ mod tests {
queue
.pending_steers
.push("Check the last command output.".to_string());
queue
.rejected_steers
.push("Rejected steer that will be retried.".to_string());
queue
.queued_messages
.push("Queued follow-up question".to_string());
@@ -3,13 +3,16 @@ source: tui_app_server/src/bottom_pane/pending_input_preview.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 52, height: 8 },
area: Rect { x: 0, y: 0, width: 52, height: 11 },
content: [
"• Messages to be submitted after next tool call ",
" (press esc to interrupt and send immediately) ",
" ↳ Please continue. ",
" ↳ Check the last command output. ",
" ",
"• Messages to be submitted at end of turn ",
" ↳ Rejected steer that will be retried. ",
" ",
"• Queued follow-up messages ",
" ↳ Queued follow-up question ",
" ⌥ + ↑ edit last queued message ",
@@ -26,9 +29,13 @@ Buffer {
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+124 -32
View File
@@ -807,6 +807,8 @@ pub(crate) struct ChatWidget {
suppress_initial_user_message_submit: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
// User messages that tried to steer a non-regular turn and must be retried first.
rejected_steers_queue: VecDeque<UserMessage>,
// Steers already submitted to core but not yet committed into history.
//
// The bottom pane shows these above queued drafts until core records the
@@ -958,9 +960,11 @@ impl ThreadComposerState {
pub(crate) struct ThreadInputState {
composer: Option<ThreadComposerState>,
pending_steers: VecDeque<UserMessage>,
rejected_steers_queue: VecDeque<UserMessage>,
queued_user_messages: VecDeque<UserMessage>,
current_collaboration_mode: CollaborationMode,
active_collaboration_mask: Option<CollaborationModeMask>,
task_running: bool,
agent_turn_running: bool,
}
@@ -2172,7 +2176,7 @@ impl ChatWidget {
let had_pending_steers = !self.pending_steers.is_empty();
self.refresh_pending_input_preview();
if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers {
if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers {
self.maybe_prompt_plan_implementation();
}
// Keep this flag for replayed completion events so a subsequent live TurnComplete can
@@ -2194,7 +2198,7 @@ impl ChatWidget {
if !self.collaboration_modes_enabled() {
return;
}
if !self.queued_user_messages.is_empty() {
if self.has_queued_follow_up_messages() {
return;
}
if self.active_mode_kind() != ModeKind::Plan {
@@ -2266,6 +2270,57 @@ impl ChatWidget {
});
}
fn has_queued_follow_up_messages(&self) -> bool {
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
}
fn pop_next_queued_user_message(&mut self) -> Option<UserMessage> {
if self.rejected_steers_queue.is_empty() {
self.queued_user_messages.pop_front()
} else {
Some(merge_user_messages(
self.rejected_steers_queue.drain(..).collect(),
))
}
}
fn pop_latest_queued_user_message(&mut self) -> Option<UserMessage> {
self.queued_user_messages
.pop_back()
.or_else(|| self.rejected_steers_queue.pop_back())
}
pub(crate) fn enqueue_rejected_steer(&mut self) -> bool {
let Some(pending_steer) = self.pending_steers.pop_front() else {
tracing::warn!(
"received active-turn-not-steerable error without a matching pending steer"
);
return false;
};
self.rejected_steers_queue
.push_back(pending_steer.user_message);
self.refresh_pending_input_preview();
true
}
#[cfg(test)]
fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool {
matches!(
codex_error_info,
CoreCodexErrorInfo::ActiveTurnNotSteerable { .. }
) && self.enqueue_rejected_steer()
}
fn handle_app_server_steer_rejected_error(
&mut self,
codex_error_info: &AppServerCodexErrorInfo,
) -> bool {
matches!(
codex_error_info,
AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. }
) && self.enqueue_rejected_steer()
}
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
let items = vec![
SelectionItem {
@@ -2508,7 +2563,11 @@ impl ChatWidget {
message: String,
codex_error_info: Option<AppServerCodexErrorInfo>,
) {
if let Some(info) = codex_error_info
if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
{
} else if let Some(info) = codex_error_info
.as_ref()
.and_then(app_server_rate_limit_error_kind)
{
@@ -2648,7 +2707,7 @@ impl ChatWidget {
/// state stays aligned with the merged attachment list. Returns `None` when there is nothing to
/// restore.
fn drain_pending_messages_for_restore(&mut self) -> Option<UserMessage> {
if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() {
if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() {
return None;
}
@@ -2660,11 +2719,12 @@ impl ChatWidget {
mention_bindings: self.bottom_pane.composer_mention_bindings(),
};
let mut to_merge: Vec<UserMessage> = self
.pending_steers
.drain(..)
.map(|steer| steer.user_message)
.collect();
let mut to_merge: Vec<UserMessage> = self.rejected_steers_queue.drain(..).collect();
to_merge.extend(
self.pending_steers
.drain(..)
.map(|steer| steer.user_message),
);
to_merge.extend(self.queued_user_messages.drain(..));
if !existing_message.text.is_empty()
|| !existing_message.local_images.is_empty()
@@ -2710,14 +2770,17 @@ impl ChatWidget {
.iter()
.map(|pending| pending.user_message.clone())
.collect(),
rejected_steers_queue: self.rejected_steers_queue.clone(),
queued_user_messages: self.queued_user_messages.clone(),
current_collaboration_mode: self.current_collaboration_mode.clone(),
active_collaboration_mask: self.active_collaboration_mask.clone(),
task_running: self.bottom_pane.is_task_running(),
agent_turn_running: self.agent_turn_running,
})
}
pub(crate) fn restore_thread_input_state(&mut self, input_state: Option<ThreadInputState>) {
let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running);
if let Some(input_state) = input_state {
self.current_collaboration_mode = input_state.current_collaboration_mode;
self.active_collaboration_mask = input_state.active_collaboration_mask;
@@ -2749,13 +2812,24 @@ impl ChatWidget {
);
self.bottom_pane.set_composer_pending_pastes(Vec::new());
}
self.pending_steers.clear();
self.queued_user_messages = input_state.pending_steers;
self.queued_user_messages
.extend(input_state.queued_user_messages);
self.pending_steers = input_state
.pending_steers
.into_iter()
.map(|user_message| PendingSteer {
compare_key: PendingSteerCompareKey {
message: user_message.text.clone(),
image_count: user_message.local_images.len()
+ user_message.remote_image_urls.len(),
},
user_message,
})
.collect();
self.rejected_steers_queue = input_state.rejected_steers_queue;
self.queued_user_messages = input_state.queued_user_messages;
} else {
self.agent_turn_running = false;
self.pending_steers.clear();
self.rejected_steers_queue.clear();
self.set_remote_image_urls(Vec::new());
self.bottom_pane.set_composer_text_with_mention_bindings(
String::new(),
@@ -2769,6 +2843,9 @@ impl ChatWidget {
self.turn_sleep_inhibitor
.set_turn_running(self.agent_turn_running);
self.update_task_running_state();
if restored_task_running && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.refresh_pending_input_preview();
self.request_redraw();
}
@@ -4244,6 +4321,7 @@ impl ChatWidget {
thread_name: None,
forked_from: None,
queued_user_messages: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding,
@@ -4381,9 +4459,9 @@ impl ChatWidget {
if key_event.kind == KeyEventKind::Press
&& self.queued_message_edit_binding.is_press(key_event)
&& !self.queued_user_messages.is_empty()
&& self.has_queued_follow_up_messages()
{
if let Some(user_message) = self.queued_user_messages.pop_back() {
if let Some(user_message) = self.pop_latest_queued_user_message() {
self.restore_user_message_to_composer(user_message);
self.refresh_pending_input_preview();
self.request_redraw();
@@ -4607,6 +4685,9 @@ impl ChatWidget {
}
SlashCommand::Compact => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
@@ -5099,10 +5180,7 @@ impl ChatWidget {
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured()
|| self.bottom_pane.is_task_running()
|| self.is_review_mode
{
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_pending_input_preview();
} else {
@@ -5117,12 +5195,6 @@ impl ChatWidget {
self.refresh_pending_input_preview();
return;
}
if self.is_review_mode {
self.queued_user_messages.push_back(user_message);
self.refresh_pending_input_preview();
return;
}
let UserMessage {
text,
local_images,
@@ -6406,7 +6478,9 @@ impl ChatWidget {
}
EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message, ..
}) => self.on_task_complete(last_agent_message, from_replay),
}) => {
self.on_task_complete(last_agent_message, from_replay);
}
EventMsg::TokenCount(ev) => {
self.set_token_info(ev.info);
self.on_rate_limit_snapshot(ev.rate_limits);
@@ -6418,8 +6492,13 @@ impl ChatWidget {
message,
codex_error_info,
}) => {
if let Some(info) = codex_error_info
&& let Some(kind) = core_rate_limit_error_kind(&info)
if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_steer_rejected_error(info))
{
} else if let Some(kind) = codex_error_info
.as_ref()
.and_then(core_rate_limit_error_kind)
{
match kind {
RateLimitErrorKind::ServerOverloaded => {
@@ -6784,7 +6863,7 @@ impl ChatWidget {
if self.bottom_pane.is_task_running() {
return;
}
if let Some(user_message) = self.queued_user_messages.pop_front() {
if let Some(user_message) = self.pop_next_queued_user_message() {
self.submit_user_message(user_message);
}
// Update the list to reflect the remaining queued messages (if any).
@@ -6803,8 +6882,16 @@ impl ChatWidget {
.iter()
.map(|steer| steer.user_message.text.clone())
.collect();
self.bottom_pane
.set_pending_input_preview(queued_messages, pending_steers);
let rejected_steers: Vec<String> = self
.rejected_steers_queue
.iter()
.map(|message| message.text.clone())
.collect();
self.bottom_pane.set_pending_input_preview(
queued_messages,
pending_steers,
rejected_steers,
);
}
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
@@ -10035,9 +10122,14 @@ impl ChatWidget {
#[cfg(test)]
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
self.queued_user_messages
self.rejected_steers_queue
.iter()
.map(|message| message.text.clone())
.chain(
self.queued_user_messages
.iter()
.map(|message| message.text.clone()),
)
.collect()
}
@@ -0,0 +1,21 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
↳ Steer submitted while /compact was running.
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -11,11 +11,11 @@ expression: term.backend().vt100().screen().contents()
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ Queued while /review is running.
⌥ + ↑ edit last queued message
• Messages to be submitted at end of turn
↳ Steer submitted while /review was running.
› Ask Codex to do anything
+344 -9
View File
@@ -112,6 +112,7 @@ use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
@@ -1560,6 +1561,131 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
assert!(chat.is_review_mode);
}
#[tokio::test]
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later"));
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
assert_eq!(chat.pending_steers.len(), 2);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected second running-turn steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected-1".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
chat.handle_codex_event(Event {
id: "steer-rejected-2".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec![
"review follow-up one",
"review follow-up two",
"queued later"
]
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "review-exit".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
});
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "review follow-up one\nreview follow-up two".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "turn-complete-2".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-2".to_string(),
last_agent_message: None,
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued later".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued draft submit after rejected steers, got {other:?}"),
}
}
#[tokio::test]
async fn live_agent_message_renders_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
@@ -1934,6 +2060,7 @@ async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
@@ -3716,9 +3843,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
queued_user_messages: VecDeque::new(),
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: true,
agent_turn_running: true,
}));
@@ -3733,6 +3862,38 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let mut pending_steers = VecDeque::new();
pending_steers.push_back(UserMessage::from("pending steer"));
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
queued_user_messages.push_back(UserMessage::from("queued draft"));
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
rejected_steers_queue,
queued_user_messages,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: false,
agent_turn_running: false,
}));
assert_eq!(
chat.queued_user_message_texts(),
vec!["already rejected", "queued draft"]
);
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
"pending steer"
);
}
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4131,6 +4292,107 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn submit_user_message_queues_while_compaction_turn_is_running() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
},
}),
None,
);
chat.submit_user_message(UserMessage::from("queued while compacting"));
assert_eq!(chat.pending_steers.len(), 1);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn compact steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
assert!(chat.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec!["queued while compacting"]
);
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::Completed,
error: None,
},
}),
None,
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued while compacting".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"),
}
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Compact);
assert!(chat.bottom_pane.is_task_running());
match rx.try_recv() {
Ok(AppEvent::CodexOp(Op::Compact)) => {}
other => panic!("expected compact op to be submitted, got {other:?}"),
}
chat.bottom_pane.set_composer_text(
"queued before compact turn start".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.pending_steers.is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued before compact turn start"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -11971,9 +12233,17 @@ async fn chatwidget_tall() {
}
#[tokio::test]
async fn enter_queues_user_messages_while_review_is_running() {
async fn enter_submits_steer_while_review_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -11985,19 +12255,28 @@ async fn enter_queues_user_messages_while_review_is_running() {
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
"Queued while /review is running.".to_string(),
"Steer submitted while /review was running.".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"Queued while /review is running."
chat.pending_steers.front().unwrap().user_message.text,
"Steer submitted while /review was running."
);
assert!(chat.pending_steers.is_empty());
assert_no_submit_op(&mut op_rx);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "Steer submitted while /review was running.".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected running-turn steer submit, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -12005,6 +12284,14 @@ async fn enter_queues_user_messages_while_review_is_running() {
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-1".into(),
@@ -12015,9 +12302,57 @@ async fn review_queues_user_messages_snapshot() {
});
let _ = drain_insert_history(&mut rx);
chat.queue_user_message(UserMessage::from(
"Queued while /review is running.".to_string(),
chat.submit_user_message(UserMessage::from(
"Steer submitted while /review was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
}
#[tokio::test]
async fn compact_queues_user_messages_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.submit_user_message(UserMessage::from(
"Steer submitted while /compact was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
}),
});
let width: u16 = 80;
let height: u16 = 18;