Skip to content
Open
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
5 changes: 5 additions & 0 deletions codex-rs/config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,11 @@ pub struct Tui {
#[serde(default)]
pub usage_limit_resume_prompt: Option<String>,

/// Automatically submit `Continue` after a turn fails with `ServerOverloaded`.
/// Defaults to `true`.
#[serde(default = "default_true")]
pub server_overloaded_resume_enabled: bool,

/// Startup tooltip availability NUX state persisted by the TUI.
#[serde(default)]
pub model_availability_nux: ModelAvailabilityNuxConfig,
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3316,6 +3316,11 @@
"default": null,
"description": "Working directory to use when resuming or forking a session. When unset, prompt if the current and session directories differ."
},
"server_overloaded_resume_enabled": {
"default": true,
"description": "Automatically submit `Continue` after a turn fails with `ServerOverloaded`. Defaults to `true`.",
"type": "boolean"
},
"session_picker_view": {
"allOf": [
{
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,9 @@ pub struct Config {
/// automatic recovery turn.
pub tui_usage_limit_resume_prompt: Option<String>,

/// Whether to automatically submit `Continue` after a turn fails with `ServerOverloaded`.
pub tui_server_overloaded_resume_enabled: bool,

/// The absolute directory that should be treated as the current working
/// directory for the session. All relative paths inside the business-logic
/// layer are resolved against this path.
Expand Down Expand Up @@ -4117,6 +4120,11 @@ impl Config {
.tui
.as_ref()
.and_then(|t| t.usage_limit_resume_prompt.clone()),
tui_server_overloaded_resume_enabled: cfg
.tui
.as_ref()
.map(|t| t.server_overloaded_resume_enabled)
.unwrap_or(true),
otel,
};
Ok(config)
Expand Down
35 changes: 27 additions & 8 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,12 @@ const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"];
const DEFAULT_USAGE_LIMIT_RESUME_PROMPT: &str =
"The usage limit has been reset, so you can resume from where you left off.";
const DEFAULT_SERVER_OVERLOADED_RESUME_PROMPT: &str = "Continue";

struct PendingLocalUserMessageEcho {
display: UserMessageDisplay,
turn_id: Option<String>,
}

/// Common initialization parameters shared by all `ChatWidget` constructors.
pub(crate) struct ChatWidgetInit {
Expand Down Expand Up @@ -764,10 +770,11 @@ pub(crate) struct ChatWidget {
current_goal_status_indicator: Option<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>,
external_editor_state: ExternalEditorState,
last_rendered_user_message_display: Option<UserMessageDisplay>,
pending_local_user_message_echo: Option<PendingLocalUserMessageEcho>,
last_non_retry_error: Option<(String, String)>,
pending_auth_reload_attempt: Option<u8>,
pending_usage_limit_resume_turn: Option<UserMessage>,
pending_server_overloaded_resume_turn: Option<UserMessage>,
usage_limit_resume_waiting_for_auth_reload: bool,
}

Expand Down Expand Up @@ -1268,7 +1275,12 @@ impl ChatWidget {
self.request_redraw();
}

fn on_committed_user_message(&mut self, items: &[UserInput], from_replay: bool) {
fn on_committed_user_message(
&mut self,
items: &[UserInput],
turn_id: &str,
from_replay: bool,
) {
let display = Self::user_message_display_from_inputs(items);
if from_replay {
if self.review.is_review_mode {
Expand Down Expand Up @@ -1299,21 +1311,28 @@ impl ChatWidget {
let pending_display =
user_message_display_for_history(pending.user_message, &pending.history_record);
self.on_user_message_display(pending_display);
} else if self.last_rendered_user_message_display.as_ref() != Some(&display) {
} else {
tracing::warn!(
"pending steer matched compare key but queue was empty when rendering committed user message"
);
self.on_user_message_display(display);
}
} else if !self.review.is_review_mode
&& self.last_rendered_user_message_display.as_ref() != Some(&display)
{
self.on_user_message_display(display);
} else if !self.review.is_review_mode {
let is_local_echo = self
.pending_local_user_message_echo
.as_ref()
.is_some_and(|pending| {
pending.turn_id.as_deref() == Some(turn_id) && pending.display == display
});
if is_local_echo {
self.pending_local_user_message_echo = None;
} else {
self.on_user_message_display(display);
}
}
}

fn on_user_message_display(&mut self, display: UserMessageDisplay) {
self.last_rendered_user_message_display = Some(display.clone());
if !display.message.trim().is_empty()
|| !display.text_elements.is_empty()
|| !display.local_images.is_empty()
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,11 @@ impl ChatWidget {
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
last_rendered_user_message_display: None,
pending_local_user_message_echo: None,
last_non_retry_error: None,
pending_auth_reload_attempt: None,
pending_usage_limit_resume_turn: None,
pending_server_overloaded_resume_turn: None,
usage_limit_resume_waiting_for_auth_reload: false,
};

Expand Down
17 changes: 12 additions & 5 deletions codex-rs/tui/src/chatwidget/input_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,20 @@ impl ChatWidget {

/// If idle and there are queued inputs, submit exactly one to start the next turn.
pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool {
if self.input_queue.suppress_queue_autosend {
if self.blocks_direct_input {
return false;
}
if self.blocks_direct_input {
if self.is_user_turn_pending_or_running() {
return false;
}
if let Some(user_message) = self.pending_server_overloaded_resume_turn.take() {
self.reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
self.refresh_pending_input_preview();
return true;
}
if self.input_queue.suppress_queue_autosend {
return false;
}
if self.pending_auth_reload_attempt.is_some() {
Expand All @@ -146,9 +156,6 @@ impl ChatWidget {
{
return false;
}
if self.is_user_turn_pending_or_running() {
return false;
}
let mut submitted_follow_up = false;
if let Some(user_message) = self.pending_usage_limit_resume_turn.take() {
self.usage_limit_resume_waiting_for_auth_reload = false;
Expand Down
12 changes: 10 additions & 2 deletions codex-rs/tui/src/chatwidget/input_submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,21 @@ impl ChatWidget {
let render_before_submit =
render_in_history && matches!(&self.codex_op_target, CodexOpTarget::AppEvent);
if render_before_submit {
self.on_user_message_display(user_message_display_for_history(
let display = user_message_display_for_history(
submitted_message.clone(),
&history_record,
));
);
self.pending_local_user_message_echo = Some(PendingLocalUserMessageEcho {
display: display.clone(),
turn_id: None,
});
self.on_user_message_display(display);
}

if !self.submit_op(op.clone()) {
if render_before_submit {
self.pending_local_user_message_echo = None;
}
return (false, None);
}
if render_in_history {
Expand Down
16 changes: 12 additions & 4 deletions codex-rs/tui/src/chatwidget/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ impl ChatWidget {
self.on_thread_settings_updated(notification);
}
ServerNotification::TurnStarted(notification) => {
if let Some(pending) = self.pending_local_user_message_echo.as_mut()
&& pending.turn_id.is_none()
{
pending.turn_id = Some(notification.turn.id.clone());
}
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) {
Expand Down Expand Up @@ -237,10 +242,13 @@ impl ChatWidget {
notification: TurnCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
// User-message dedupe only suppresses the app-server echo of a prompt
// this TUI already rendered locally. Once that turn ends, another
// client can submit the same text and it still needs its own user cell.
self.last_rendered_user_message_display = None;
if self
.pending_local_user_message_echo
.as_ref()
.is_some_and(|pending| pending.turn_id.as_deref() == Some(&notification.turn.id))
{
self.pending_local_user_message_echo = None;
}
match notification.turn.status {
TurnStatus::Completed => {
self.last_non_retry_error = None;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/chatwidget/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ impl ChatWidget {
let replay_kind = render_source.replay_kind();
match item {
ThreadItem::UserMessage { content, .. } => {
self.on_committed_user_message(&content, from_replay);
self.on_committed_user_message(&content, &turn_id, from_replay);
}
ThreadItem::AgentMessage {
id,
Expand Down
6 changes: 5 additions & 1 deletion codex-rs/tui/src/chatwidget/safety_buffering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ impl ChatWidget {
}

pub(crate) fn prepare_safety_buffered_retry_submission(&mut self, prompt: UserMessage) {
self.last_rendered_user_message_display = None;
self.pending_local_user_message_echo = None;
self.finalize_turn();
self.safety_buffering_prompt = Some(prompt);
self.input_queue.user_turn_pending_start = true;
}

pub(crate) fn commit_safety_buffered_retry_submission(&mut self, display: UserMessageDisplay) {
self.pending_local_user_message_echo = Some(PendingLocalUserMessageEcho {
display: display.clone(),
turn_id: None,
});
self.on_user_message_display(display);
}

Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/chatwidget/turn_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,11 @@ impl ChatWidget {

pub(super) fn on_server_overloaded_error(&mut self, message: String) {
self.input_queue.submit_pending_steers_after_interrupt = false;
if self.config.tui_server_overloaded_resume_enabled {
self.pending_server_overloaded_resume_turn = Some(UserMessage::from(
DEFAULT_SERVER_OVERLOADED_RESUME_PROMPT,
));
}
self.finalize_turn();

let message = if message.trim().is_empty() {
Expand Down