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
3 changes: 0 additions & 3 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,6 @@ impl App {
.add_error_message(format!("Failed to retry with a faster model: {err}"));
}
}
AppEvent::RestoreCancelledTurn(prompt) => {
self.apply_cancelled_turn_edit(prompt);
}
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
self.append_message_history_entry(thread_id, text);
}
Expand Down

This file was deleted.

110 changes: 55 additions & 55 deletions codex-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,61 @@ async fn replay_thread_snapshot_restores_draft_and_queued_input() {
}
}

#[tokio::test]
async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(thread_id).await;
app.chat_widget.handle_thread_session(session);
let default_mode = CollaborationModeMask {
name: "Default".to_string(),
mode: None,
model: None,
reasoning_effort: None,
developer_instructions: None,
};
app.chat_widget
.submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone());
let expected_input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected thread input state");

app.store_active_thread_receiver().await;
let snapshot = {
let channel = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel should exist");
let store = channel.store.lock().await;
assert_eq!(store.input_state, Some(expected_input_state.clone()));
store.snapshot()
};

let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
chat_widget.handle_thread_session(test_thread_session(
ThreadId::new(),
test_path_buf("/tmp/other-project"),
));
chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode);
app.chat_widget = chat_widget;
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);

assert_eq!(
app.chat_widget.capture_thread_input_state(),
Some(expected_input_state)
);
}

#[tokio::test]
async fn active_turn_id_for_thread_uses_snapshot_turns() {
let mut app = make_test_app().await;
Expand Down Expand Up @@ -5283,61 +5338,6 @@ async fn backtrack_remote_image_only_selection_clears_existing_composer_draft()
assert_eq!(rollback_turns, Some(1));
}

#[tokio::test]
async fn cancelled_turn_edit_restores_prompt_and_rolls_back_latest_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "original".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>];
let prompt = crate::chatwidget::UserMessage {
text: "edit me".to_string(),
local_images: Vec::new(),
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};

app.apply_cancelled_turn_edit(prompt);

assert_eq!(app.chat_widget.composer_text_with_pending(), "edit me");
assert_snapshot!(
"cancelled_turn_edit_restores_composer",
app.chat_widget.composer_text_with_pending()
);
assert_eq!(
app.chat_widget.remote_image_urls(),
vec!["https://example.com/edit.png".to_string()]
);
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
}

#[tokio::test]
async fn first_cancelled_turn_edit_restores_prompt_without_local_history() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let prompt = crate::chatwidget::UserMessage {
text: "edit first prompt".to_string(),
local_images: Vec::new(),
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};

app.apply_cancelled_turn_edit(prompt);

assert_eq!(
app.chat_widget.composer_text_with_pending(),
"edit first prompt"
);
assert_eq!(
app.chat_widget.remote_image_urls(),
vec!["https://example.com/edit.png".to_string()]
);
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
}

#[tokio::test]
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app/thread_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ impl App {
op: &AppCommand,
) -> Result<bool> {
match op {
AppCommand::Interrupt { .. } => {
AppCommand::Interrupt => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
let mut interrupt_turn_id = turn_id;
for retried_after_turn_mismatch in [false, true] {
Expand Down
33 changes: 0 additions & 33 deletions codex-rs/tui/src/app_backtrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ use std::sync::Arc;
use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::chatwidget::UserMessage;
#[cfg(test)]
use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
Expand Down Expand Up @@ -239,38 +238,6 @@ impl App {
}
}

pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) {
let user_total = user_count(&self.transcript_cells);
let selection = BacktrackSelection {
nth_user_message: user_total.saturating_sub(1),
prefill: prompt.text.clone(),
text_elements: prompt.text_elements.clone(),
local_image_paths: prompt
.local_images
.iter()
.map(|image| image.path.clone())
.collect(),
remote_image_urls: prompt.remote_image_urls.clone(),
};
if user_total == 0 {
if self.backtrack.pending_rollback.is_some() {
self.chat_widget
.add_error_message("Backtrack rollback already in progress.".to_string());
return;
}
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
selection,
thread_id: self.chat_widget.thread_id(),
});
self.chat_widget
.submit_op(AppCommand::thread_rollback(/*num_turns*/ 1));
self.chat_widget.restore_user_message_to_composer(prompt);
return;
}
self.apply_backtrack_rollback(selection);
self.chat_widget.restore_user_message_to_composer(prompt);
}

/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.enter_alt_screen();
Expand Down
20 changes: 2 additions & 18 deletions codex-rs/tui/src/app_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ use serde_json::Value;
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) enum AppCommand {
Interrupt {
behavior: InterruptBehavior,
},
Interrupt,
CleanBackgroundTerminals,
RunUserShellCommand {
command: String,
Expand Down Expand Up @@ -104,23 +102,9 @@ pub(crate) enum AppCommand {
},
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) enum InterruptBehavior {
Default,
RestorePromptIfNoOutput,
}

impl AppCommand {
pub(crate) fn interrupt() -> Self {
Self::Interrupt {
behavior: InterruptBehavior::Default,
}
}

pub(crate) fn interrupt_and_restore_prompt_if_no_output() -> Self {
Self::Interrupt {
behavior: InterruptBehavior::RestorePromptIfNoOutput,
}
Self::Interrupt
}

pub(crate) fn clean_background_terminals() -> Self {
Expand Down
3 changes: 0 additions & 3 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,6 @@ pub(crate) enum AppEvent {
/// bubbling channels through layers of widgets.
CodexOp(AppCommand),

/// Restore an output-free interrupted turn into the composer and roll it back.
RestoreCancelledTurn(UserMessage),

/// Approve one retry of a recent auto-review denial selected in the TUI.
ApproveRecentAutoReviewDenial {
thread_id: ThreadId,
Expand Down
6 changes: 0 additions & 6 deletions codex-rs/tui/src/app_event_sender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,6 @@ impl AppEventSender {
self.send(AppEvent::CodexOp(AppCommand::interrupt()));
}

pub(crate) fn interrupt_and_restore_prompt_if_no_output(&self) {
self.send(AppEvent::CodexOp(
AppCommand::interrupt_and_restore_prompt_if_no_output(),
));
}

pub(crate) fn compact(&self) {
self.send(AppEvent::CodexOp(AppCommand::compact()));
}
Expand Down
12 changes: 6 additions & 6 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2655,7 +2655,7 @@ mod tests {

while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt when dismissing skill popup"
);
}
Expand Down Expand Up @@ -2694,7 +2694,7 @@ mod tests {

while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt while command popup is active"
);
}
Expand Down Expand Up @@ -2741,7 +2741,7 @@ mod tests {

while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt while typing `/agent`"
);
}
Expand Down Expand Up @@ -2786,7 +2786,7 @@ mod tests {

while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc release after dismissing agent picker to not interrupt"
);
}
Expand Down Expand Up @@ -2816,7 +2816,7 @@ mod tests {
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt while a task is running"
);
}
Expand All @@ -2840,7 +2840,7 @@ mod tests {

pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected configured key to interrupt while `/agent` is being edited"
);
}
Expand Down
19 changes: 2 additions & 17 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ pub(crate) struct ChatWidget {
// order.
suppress_initial_user_message_submit: bool,
input_queue: InputQueueState,
cancel_edit: CancelEditState,
safety_buffering_prompt: Option<UserMessage>,
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
chat_keymap: ChatKeymap,
/// Keybinding to show for popping the most-recently queued message back
Expand Down Expand Up @@ -787,13 +787,6 @@ pub(crate) enum InterruptedTurnNoticeMode {
Suppress,
}

#[derive(Debug, Default)]
struct CancelEditState {
prompt: Option<UserMessage>,
eligible: bool,
armed: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ReplayKind {
ResumeInitialMessages,
Expand Down Expand Up @@ -1219,9 +1212,6 @@ impl ChatWidget {
}

fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
if self.turn_lifecycle.agent_turn_running && !cell.display_lines(u16::MAX).is_empty() {
self.record_visible_turn_activity();
}
// Keep the placeholder session header as the active cell until real session info arrives,
// so we can merge headers instead of committing a duplicate box to history.
let keep_placeholder_header_active = !self.is_session_configured()
Expand Down Expand Up @@ -1840,12 +1830,7 @@ impl ChatWidget {
}

pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) {
if let AppCommand::Interrupt { behavior } = op
&& self.turn_lifecycle.agent_turn_running
{
if *behavior == crate::app_command::InterruptBehavior::RestorePromptIfNoOutput {
self.arm_cancel_edit();
}
if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running {
if let Some(controller) = self.stream_controller.as_mut() {
controller.clear_queue();
}
Expand Down
1 change: 0 additions & 1 deletion codex-rs/tui/src/chatwidget/command_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ impl ChatWidget {
}

pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
self.record_visible_turn_activity();
let ThreadItem::CommandExecution {
id,
command,
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ impl ChatWidget {
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
input_queue: InputQueueState::default(),
cancel_edit: CancelEditState::default(),
safety_buffering_prompt: None,
chat_keymap,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
Expand Down
1 change: 0 additions & 1 deletion codex-rs/tui/src/chatwidget/hook_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ impl ChatWidget {
}

pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
self.record_visible_turn_activity();
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
match self.active_hook_cell.as_mut() {
Expand Down
Loading
Loading