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
2 changes: 2 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub struct TuiGlobalKeymap {
pub struct TuiChatKeymap {
/// Interrupt the active turn.
pub interrupt_turn: Option<KeybindingsSpec>,
/// Flush all queued follow-up messages into the active turn as one steer.
pub flush_queued_messages: Option<KeybindingsSpec>,
/// Decrease the active reasoning effort.
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
/// Increase the active reasoning effort.
Expand Down
12 changes: 11 additions & 1 deletion codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3114,6 +3114,7 @@
"chat": {
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"flush_queued_messages": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
},
Expand Down Expand Up @@ -3446,6 +3447,14 @@
],
"description": "Edit the most recently queued message."
},
"flush_queued_messages": {
"anyOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Flush all queued follow-up messages into the active turn as one steer."
},
"increase_reasoning_effort": {
"anyOf": [
{
Expand Down Expand Up @@ -3780,6 +3789,7 @@
"default": {
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"flush_queued_messages": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
}
Expand Down Expand Up @@ -5764,4 +5774,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
5 changes: 4 additions & 1 deletion codex-rs/tui/src/app/thread_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,10 @@ impl App {
if let Some(turn_error) =
active_turn_not_steerable_turn_error(&error)
{
if !self.chat_widget.enqueue_rejected_steer() {
if !self
.chat_widget
.enqueue_rejected_steer_matching_items(items)
{
self.chat_widget.add_error_message(turn_error.message);
}
return Ok(true);
Expand Down
18 changes: 18 additions & 0 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ pub(crate) struct ComposerDraftSnapshot {
pub(crate) remote_image_urls: Vec<String>,
pub(crate) mention_bindings: Vec<MentionBinding>,
pub(crate) pending_pastes: Vec<(String, String)>,
cursor: usize,
}

const FOOTER_SPACING_HEIGHT: u16 = 0;
Expand Down Expand Up @@ -1476,9 +1477,26 @@ impl ChatComposer {
remote_image_urls: self.remote_image_urls(),
mention_bindings: self.mention_bindings(),
pending_pastes: self.pending_pastes(),
cursor: self.current_cursor(),
}
}

pub(crate) fn restore_draft_snapshot(&mut self, snapshot: ComposerDraftSnapshot) {
self.restore_draft(ComposerDraft {
text: snapshot.text,
text_elements: snapshot.text_elements,
local_image_paths: snapshot
.local_images
.into_iter()
.map(|image| image.path)
.collect(),
remote_image_urls: snapshot.remote_image_urls,
mention_bindings: snapshot.mention_bindings,
pending_pastes: snapshot.pending_pastes,
cursor: snapshot.cursor,
});
}

#[cfg(test)]
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
self.attachments.local_image_paths()
Expand Down
14 changes: 14 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ impl BottomPane {
self.request_redraw();
}

/// Update the key hint for immediately submitting every queued follow-up.
pub(crate) fn set_queued_message_flush_binding(&mut self, binding: Option<KeyBinding>) {
self.pending_input_preview.set_flush_binding(binding);
self.request_redraw();
}

pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.composer.set_vim_enabled(enabled);
self.request_redraw();
Expand Down Expand Up @@ -858,6 +864,14 @@ impl BottomPane {
self.composer.draft_snapshot()
}

pub(crate) fn restore_composer_draft_snapshot(
&mut self,
snapshot: chat_composer::ComposerDraftSnapshot,
) {
self.composer.restore_draft_snapshot(snapshot);
self.request_redraw();
}

#[cfg(test)]
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
self.composer.text_elements()
Expand Down
42 changes: 39 additions & 3 deletions codex-rs/tui/src/bottom_pane/pending_input_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ use crate::wrapping::adaptive_wrap_lines;
/// boundary unless the user invokes the interrupt binding to send them
/// immediately. The edit hint at the bottom only appears when there are actual
/// queued user inputs 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).
/// intercept certain modifier-key combinations, the displayed bindings are
/// supplied by the resolved runtime keymap.
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.
edit_binding: Option<key_hint::KeyBinding>,
/// Key combination rendered for submitting all queued follow-ups now.
flush_binding: Option<key_hint::KeyBinding>,
/// Key combination rendered for immediately interrupting and sending steers.
interrupt_binding: Option<key_hint::KeyBinding>,
}
Expand All @@ -40,6 +42,7 @@ impl PendingInputPreview {
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: Some(key_hint::alt(KeyCode::Up)),
flush_binding: None,
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
}
}
Expand All @@ -51,6 +54,10 @@ impl PendingInputPreview {
self.edit_binding = binding;
}

pub(crate) fn set_flush_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
self.flush_binding = binding;
}

pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
self.interrupt_binding = binding;
}
Expand Down Expand Up @@ -151,6 +158,19 @@ impl PendingInputPreview {
}
}

if (!self.rejected_steers.is_empty() || !self.queued_messages.is_empty())
&& let Some(flush_binding) = self.flush_binding
{
lines.push(
Line::from(vec![
" ".into(),
flush_binding.into(),
" submit all now".into(),
])
.dim(),
);
}

if !self.queued_messages.is_empty()
&& let Some(edit_binding) = self.edit_binding
{
Expand Down Expand Up @@ -205,6 +225,7 @@ mod tests {
fn render_one_message() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue.set_flush_binding(Some(key_hint::shift(KeyCode::Enter)));
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
Expand Down Expand Up @@ -302,7 +323,7 @@ mod tests {
let height = queue.desired_height(width);
assert_eq!(
height, 3,
"expected header, one message row, and hint row for URL-like token"
"expected header, one message row, and one hint row for URL-like token"
);

let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
Expand Down Expand Up @@ -348,6 +369,21 @@ mod tests {
);
}

#[test]
fn render_queued_message_with_remapped_flush_binding() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Please continue.".to_string());
queue.set_flush_binding(Some(key_hint::plain(KeyCode::F(12))));
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!(
"render_queued_message_with_remapped_flush_binding",
format!("{buf:?}")
);
}

#[test]
fn render_pending_steers_above_queued_messages() {
let mut queue = PendingInputPreview::new();
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::TextElement;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ impl ChatWidget {
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
widget
.bottom_pane
.set_queued_message_flush_binding(
widget.chat_keymap.flush_queued_messages.first().copied(),
);
#[cfg(target_os = "windows")]
widget.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
Expand Down
100 changes: 100 additions & 0 deletions codex-rs/tui/src/chatwidget/input_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,106 @@ impl ChatWidget {
}
}

/// Merge every queued follow-up visible at this keypress into one active-turn steer.
///
/// Rejected steers retain dequeue priority, followed by locally queued messages in FIFO
/// order. Queued slash and shell actions are intentionally literalized into the merged steer,
/// and shell escape handling stays disabled so no queued text executes locally. Cloning all
/// four deques preserves their message/history alignment until the merged submission is
/// accepted, while leaving already-submitted pending steers and the live composer draft
/// untouched.
pub(super) fn flush_queued_messages(&mut self) {
let queued_message_count = self.input_queue.queued_user_messages.len();
let queued_history_count = self.input_queue.queued_user_message_history_records.len();
let rejected_messages = self
.input_queue
.rejected_steers_queue
.iter()
.cloned()
.collect::<Vec<_>>();
let mut rejected_history_records = self
.input_queue
.rejected_steer_history_records
.iter()
.cloned()
.collect::<Vec<_>>();
rejected_history_records.resize(
rejected_messages.len(),
UserMessageHistoryRecord::UserMessageText,
);

let queued_messages = self
.input_queue
.queued_user_messages
.iter()
.cloned()
.collect::<Vec<_>>();
let mut queued_history_records = self
.input_queue
.queued_user_message_history_records
.iter()
.cloned()
.collect::<Vec<_>>();
queued_history_records.resize(
queued_messages.len(),
UserMessageHistoryRecord::UserMessageText,
);

let mut messages = rejected_messages
.into_iter()
.zip(rejected_history_records)
.collect::<Vec<_>>();
messages.extend(
queued_messages
.into_iter()
.zip(queued_history_records)
.map(|(message, history_record)| (message.into_user_message(), history_record)),
);
let (message, history_record) = merge_user_messages_with_history_record(messages);
let actual_chars = message.text.chars().count();
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
self.add_error_message(format!(
"Queued messages exceed the maximum combined length of \
{MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided). \
Edit or remove queued messages before submitting them together."
));
return;
}
let composer_snapshot = self.bottom_pane.composer_draft_snapshot();
let accepted = self.resubmit_queued_user_message_with_history_record(
message,
history_record,
ShellEscapePolicy::Disallow,
);
if accepted {
self.input_queue.rejected_steers_queue.clear();
self.input_queue.rejected_steer_history_records.clear();
// A successful resubmit can queue the merged message at the front while session,
// auth, or usage-limit state is being resolved. Remove only the original entries
// from the back so that accepted replacement survives.
let queued_message_retained_count = self
.input_queue
.queued_user_messages
.len()
.saturating_sub(queued_message_count);
self.input_queue
.queued_user_messages
.truncate(queued_message_retained_count);
let queued_history_retained_count = self
.input_queue
.queued_user_message_history_records
.len()
.saturating_sub(queued_history_count);
self.input_queue
.queued_user_message_history_records
.truncate(queued_history_retained_count);
} else {
self.bottom_pane
.restore_composer_draft_snapshot(composer_snapshot);
}
self.refresh_pending_input_preview();
}

/// 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 {
Expand Down
22 changes: 21 additions & 1 deletion codex-rs/tui/src/chatwidget/input_restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,28 @@ impl ChatWidget {
}
}

pub(crate) fn enqueue_rejected_steer_matching_items(&mut self, items: &[UserInput]) -> bool {
let compare_key = Self::pending_steer_compare_key_from_items(items);
let Some(index) = self
.input_queue
.pending_steers
.iter()
.position(|pending| pending.compare_key == compare_key)
else {
tracing::warn!(
"received active-turn-not-steerable response without a matching pending steer"
);
return false;
};
self.enqueue_rejected_steer_at(index)
}

pub(crate) fn enqueue_rejected_steer(&mut self) -> bool {
let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else {
self.enqueue_rejected_steer_at(0)
}

fn enqueue_rejected_steer_at(&mut self, index: usize) -> bool {
let Some(pending_steer) = self.input_queue.pending_steers.remove(index) else {
tracing::warn!(
"received active-turn-not-steerable error without a matching pending steer"
);
Expand Down
16 changes: 16 additions & 0 deletions codex-rs/tui/src/chatwidget/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ impl ChatWidget {
return;
}

if self.turn_lifecycle.agent_turn_running
&& self
.chat_keymap
.flush_queued_messages
.is_pressed(key_event)
&& self.bottom_pane.no_modal_or_popup_active()
{
if key_event.kind == KeyEventKind::Repeat {
return;
}
if key_event.kind == KeyEventKind::Press && self.has_queued_follow_up_messages() {
self.flush_queued_messages();
return;
}
}

if self.chat_keymap.interrupt_turn.is_pressed(key_event)
&& !self.input_queue.pending_steers.is_empty()
&& self.bottom_pane.is_task_running()
Expand Down
Loading
Loading