diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index 063c556a3c..b0ff6f4d75 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -1,5 +1,6 @@ //! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay. //! Local submissions and committed desktop replies remove questions; arrival never steals focus. +//! Live turn completion recovers unsent typed drafts before removing pending questions. use crate::app_event_sender::AppEventSender; use crate::bottom_pane::CancellationEvent; diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state.rs b/codex-rs/tui/src/bottom_pane/async_questions/state.rs index 4e33b14640..666405debd 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state.rs @@ -1,4 +1,4 @@ -//! Pending async questions retain drafts until handled locally or answered by another client. +//! Pending async questions retain drafts until handled locally, answered by another client, or a live turn ends. //! Message IDs survive removal so replay cannot reopen an answered or skipped question. use super::*; @@ -206,6 +206,20 @@ impl AsyncQuestions { self.composer.reset_vim_mode(); } + /// Recover unsent typed answers before clearing all pending questions. + pub(crate) fn take_pending_drafts(&mut self) -> Vec { + self.save_current_draft(); + let drafts = self + .state + .pending + .iter() + .map(|question| question.draft.text_with_pending().trim().to_string()) + .filter(|text| !text.is_empty()) + .collect(); + self.clear_pending(); + drafts + } + pub(crate) fn capture(&mut self) -> QuestionState { self.composer.cancel_history_search(); self.save_current_draft(); diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index 7823b72e11..d98d412d1a 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -28,6 +28,25 @@ fn editor() -> AsyncQuestions { editor } +#[test] +fn taking_pending_drafts_preserves_order_expands_pastes_and_skips_blank_drafts() { + let mut editor = editor(); + editor.set_expanded(/*expanded*/ true); + let large_answer = "typed answer ".repeat(/*n*/ 200); + editor.handle_paste(large_answer.clone()); + editor.navigate(/*forward*/ true); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + editor.handle_paste(" other answer ".into()); + editor.append("blank", &[question("Blank?", /*options*/ None)]); + + assert_eq!( + editor.take_pending_drafts(), + vec![large_answer.trim().to_string(), "other answer".to_string()] + ); + assert_eq!((editor.unanswered_count(), editor.expanded), (0, false)); + assert!(editor.submission.is_none()); +} + #[test] fn replay_preserves_drafts_and_does_not_reopen_handled_questions() { let mut original = editor(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4bb2998897..041ee1bffa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -85,6 +85,13 @@ //! recall is a two-phase handoff: stage the submitted slash text here, then record it after //! `ChatWidget` dispatches the command. //! +//! # Question Draft Recovery +//! +//! Live terminal turns recover typed, unsubmitted question answers into the main composer. +//! The append flushes buffered input, dismisses unused sparkle eligibility, and adds a newline +//! after existing text. The separator and recovered answer form one Vim edit; large answers +//! use atomic paste placeholders backed by their original text. +//! //! # Startup Draft Handoff //! //! Startup uses a provisional plain-text composer: editing remains available while its owner diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs index 643ff56f1e..9022ab4f77 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -1,5 +1,9 @@ //! Embed the composer in question rows, resetting history navigation when drafts change. +//! Recovered appends cancel pending Vim commands before moving the cursor and stay out of dot repeat. +//! Normal-mode recovery is one undoable edit; active insert/replace sessions keep their grouping. +//! Paste payloads stay intact and sparkle stays dismissed. +use super::super::textarea::VimPersistentState; use super::*; impl ComposerDraft { @@ -14,6 +18,39 @@ impl ComposerDraft { } impl ChatComposer { + pub(in crate::bottom_pane) fn append_recovered_drafts(&mut self, drafts: &str) { + self.flush_pending_input(); + self.dismiss_sparkle(); + if self.draft.textarea.is_vim_operator_pending() { + self.draft.textarea.enter_vim_normal_mode(); + } + self.finish_vim_edit(); + let mut vim_state = VimPersistentState::default(); + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + let started_vim_edit = self.begin_direct_vim_edit(); + self.move_cursor_to_end(); + if !self.current_text().is_empty() { + self.insert_str("\n"); + } + let char_count = drafts.chars().count(); + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + let placeholder = self.next_large_paste_placeholder(char_count); + self.draft.textarea.insert_element(&placeholder); + self.draft.pending_pastes.push((placeholder, drafts.into())); + self.sync_popups(); + } else { + self.insert_str(drafts); + } + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + if started_vim_edit { + self.finish_vim_edit(); + } + } + pub(in crate::bottom_pane) fn restore_inline_draft(&mut self, draft: ComposerDraft) { self.history.reset_navigation(); self.restore_draft(draft); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__cleared_recovered_draft_keeps_sparkle_dismissed.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__cleared_recovered_draft_keeps_sparkle_dismissed.snap new file mode 100644 index 0000000000..5cac079291 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__cleared_recovered_draft_keeps_sparkle_dismissed.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +assertion_line: 900 +expression: "text(&draw(&pane.composer, 80, now).0)" +--- + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs index 271fae1776..8e07d16ec3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs @@ -1,5 +1,6 @@ //! Covers the original starfield appearance, protected content, and composer eligibility. +use super::super::LARGE_PASTE_CHAR_THRESHOLD; use super::*; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneParams; @@ -874,7 +875,7 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_ canceled.select_sparkle_model("astra", &enabled()); assert!(!dots(&draw(&canceled.composer, /*width*/ 80, now).0).is_empty()); - for input in ["paste draft", "inserted"] { + for input in ["paste draft", "inserted", "recovered"] { let mut pane = pane(); pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled()); if input == "inserted" { @@ -884,6 +885,9 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_ InputResult::Command(SlashCommand::Mention) )); pane.composer.insert_str("@"); + } else if input == "recovered" { + pane.composer + .append_recovered_drafts(&"x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); } else { pane.handle_paste(input.to_string()); } @@ -892,6 +896,12 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_ pane.select_sparkle_model("astra", &enabled()); assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed); assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty()); + if input == "recovered" { + insta::assert_snapshot!( + "cleared_recovered_draft_keeps_sparkle_dismissed", + text(&draw(&pane.composer, /*width*/ 80, now).0) + ); + } } }); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs index f296f2eb79..311e6252d7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs @@ -78,6 +78,78 @@ fn undo_and_redo_group_complete_vim_edits() { } } +#[test] +fn recovered_answers_undo_as_one_vim_edit() { + for answer in [ + "answer".to_owned(), + "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1), + ] { + let mut composer = vim_composer("abc"); + keys(&mut composer, "iX"); + escape(&mut composer); + let before = composer.draft_snapshot(); + composer.append_recovered_drafts(&answer); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); + keys(&mut composer, "."); + assert_eq!(composer.current_text(), "XXabc"); + + for (command, mode, edited) in [("iX", "Insert", "Xabc"), ("RX", "Replace", "Xbc")] { + let mut composer = vim_composer("abc"); + let before = composer.draft_snapshot(); + keys(&mut composer, command); + composer.append_recovered_drafts(&answer); + assert_eq!( + ( + composer.current_text_with_pending(), + composer.draft.textarea.vim_mode_label(), + ), + (format!("{edited}\n{answer}"), Some(mode)) + ); + escape(&mut composer); + let after = composer.draft_snapshot(); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), after); + keys(&mut composer, "u"); + keys(&mut composer, "."); + assert_eq!(composer.current_text(), edited); + } + for command in ["", "d", "/draft", "?draft", "d/draft", "c?draft", "y/draft"] { + let mut composer = vim_composer("existing draft"); + let before = composer.draft_snapshot(); + keys(&mut composer, command); + composer.append_recovered_drafts(&answer); + assert_eq!( + ( + composer.current_text_with_pending(), + composer.draft.textarea.vim_query().is_some(), + composer.draft.textarea.is_vim_operator_pending(), + ), + (format!("existing draft\n{answer}"), false, false) + ); + let after = composer.draft_snapshot(); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), after); + } + } + snapshot_composer_state_with_width( + "recovered_answer_cancels_vim_search", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + composer.set_text_content("existing draft".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + keys(composer, "d/draft"); + composer.append_recovered_drafts("answer"); + }, + ); +} + #[test] fn undo_preserves_repeat_and_pending_operator_bindings() { let mut composer = vim_composer("alpha beta gamma"); diff --git a/codex-rs/tui/src/bottom_pane/questions.rs b/codex-rs/tui/src/bottom_pane/questions.rs index 19f1a87a8c..55d9c74b97 100644 --- a/codex-rs/tui/src/bottom_pane/questions.rs +++ b/codex-rs/tui/src/bottom_pane/questions.rs @@ -42,6 +42,28 @@ impl BottomPane { self.request_redraw(); } + /// Capture answers and settle the main draft before turn finalization can restore input. + pub(crate) fn take_question_drafts(&mut self) -> Option> { + let questions = self.questions.as_mut()?; + // The question editor closes at turn end; recover its typed answer, not a history preview. + questions.composer.cancel_history_search(); + let drafts = questions.take_pending_drafts(); + if !drafts.is_empty() { + self.composer.cancel_history_search(); + self.composer.flush_pending_input(); + } + Some(drafts) + } + + /// Append recovered answers after turn finalization, retaining existing composer elements. + pub(crate) fn append_question_drafts(&mut self, drafts: &[String]) { + if drafts.is_empty() { + return; + } + self.composer.append_recovered_drafts(&drafts.join("\n")); + self.request_redraw(); + } + pub(super) fn question_summary(&self, now: Instant) -> Option>> { let questions = self .questions diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__recovered_answer_cancels_vim_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__recovered_answer_cancels_vim_search.snap new file mode 100644 index 0000000000..fe46d2b6eb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__recovered_answer_cancels_vim_search.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› existing draft " +" answer " +" " +" " +" " +" " +" " +" 100% context left | Vim: Normal " diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index dba652336a..66e1109ef4 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -408,6 +408,7 @@ impl ChatWidget { // 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; + let mut question_drafts = None; let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion; self.thread_usage.replaying_turn_completion = replay_kind.is_some(); match notification.turn.status { @@ -453,6 +454,9 @@ impl ChatWidget { { self.speak_completed_realtime_delegation(¬ification.turn.id, item); } + if replay_kind.is_none() { + question_drafts = self.bottom_pane.take_question_drafts(); + } self.last_non_retry_error = None; let completion = self.completion_cell(¬ification.turn, replay_kind); self.on_task_complete( @@ -462,6 +466,9 @@ impl ChatWidget { ); } TurnStatus::Interrupted => { + if replay_kind.is_none() { + question_drafts = self.bottom_pane.take_question_drafts(); + } self.last_non_retry_error = None; let reason = if self .turn_lifecycle @@ -474,6 +481,9 @@ impl ChatWidget { self.on_interrupted_turn(reason); } TurnStatus::Failed => { + if replay_kind.is_none() { + question_drafts = self.bottom_pane.take_question_drafts(); + } if let Some(error) = notification.turn.error { if replay_kind.is_none() && error.codex_error_info @@ -500,6 +510,14 @@ impl ChatWidget { } TurnStatus::InProgress => {} } + if let Some(drafts) = question_drafts + && !self.has_misalignment_policy_violation() + { + // Interruption can restore queued input into the composer during finalization. + self.bottom_pane.append_question_drafts(&drafts); + self.refresh_pending_input_preview(); + self.request_redraw(); + } if replay_kind.is_none() { self.finish_realtime_turn(¬ification.turn.id); } diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs index e3266da9e6..379c996cd6 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs @@ -176,7 +176,7 @@ async fn delegation_started_before_peer_connection_keeps_its_voice_origin() { } #[tokio::test] -async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech() { +async fn delegated_async_question_stays_local_and_expires_when_its_turn_ends() { let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await; let thread_id = activate_voice(&mut chat); let turn_id = "question-turn"; @@ -199,6 +199,13 @@ async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech }; start_item(&mut chat, thread_id, turn_id, answer.clone()); complete_item(&mut chat, thread_id, turn_id, answer.clone()); + assert_eq!( + chat.bottom_pane + .questions + .as_ref() + .map(|editor| editor.unanswered_count()), + Some(1) + ); finish_turn( &mut chat, thread_id, @@ -212,7 +219,7 @@ async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech .questions .as_ref() .map(|editor| editor.unanswered_count()), - Some(1) + Some(0) ); assert!( ops.try_recv().is_err(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c8b7d20364..c0ec4ad648 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -314,6 +314,9 @@ pub(super) use helpers::*; #[path = "tests/questions_tests.rs"] mod questions_tests; +#[path = "tests/question_turn_end_tests.rs"] +mod question_turn_end_tests; + #[path = "tests/list_spacing_tests.rs"] mod list_spacing_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index ebab556cf0..809d3d59c1 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1555,7 +1555,10 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() { phase: Some(MessagePhase::FinalAnswer), memory_citation: None, delivery: None, - questions: None, + questions: Some(vec![codex_protocol::items::AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }]), }]; chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { @@ -1586,6 +1589,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() { }) .collect::>(); assert_eq!(consolidations.len(), 1); + assert_eq!(chat.bottom_pane.question_editor().unanswered_count(), 0); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs index 6037019420..8f9a249fa2 100644 --- a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs @@ -71,6 +71,16 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() { chat.queue_user_message(UserMessage::from("queued follow-up")); chat.bottom_pane .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); + chat.add_async_questions( + "question", + &[codex_protocol::items::AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }], + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.handle_paste("answer".into()); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT)); drain_insert_history(&mut rx); handle_error( diff --git a/codex-rs/tui/src/chatwidget/tests/question_turn_end_tests.rs b/codex-rs/tui/src/chatwidget/tests/question_turn_end_tests.rs new file mode 100644 index 0000000000..ff2ca3ba73 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/question_turn_end_tests.rs @@ -0,0 +1,173 @@ +//! Live terminal turns recover open and collapsed question drafts into the composer. + +use super::*; +use codex_protocol::items::AsyncUserInputQuestion; +use pretty_assertions::assert_eq; + +fn questions(chat: &mut ChatWidget, message_id: &str) { + chat.add_async_questions( + message_id, + &[AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }], + ); +} + +#[tokio::test] +async fn question_turn_end_appends_open_drafts_in_order_once() { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.show_welcome_banner = false; + handle_turn_started(&mut chat, "turn"); + chat.bottom_pane + .record_replayed_user_message_history(crate::bottom_pane::HistoryEntry::new( + "Earlier answer".into(), + )); + let element = TextElement::new((0..5).into(), Some("$tool".into())); + chat.bottom_pane.set_composer_text( + "$tool existing draft".into(), + vec![element.clone()], + Vec::new(), + ); + chat.bottom_pane.set_composer_cursor(/*cursor*/ 0); + questions(&mut chat, "first"); + questions(&mut chat, "second"); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.handle_paste(" Over-ear ".into()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.handle_paste("Under 250".into()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('e'))); + + handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None); + assert_eq!( + ( + chat.bottom_pane.composer_text(), + chat.bottom_pane.composer_text_elements(), + chat.bottom_pane.question_editor().unanswered_count() + ), + ( + "$tool existing draft\nOver-ear\nUnder 250".into(), + vec![element], + 0 + ) + ); + assert!(ops.try_recv().is_err()); + insta::assert_snapshot!( + "question_turn_end_recovered_composer", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); + + handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None); + assert_recovered_draft(&mut chat, "$tool existing draft\nOver-ear\nUnder 250"); + + questions(&mut chat, "large"); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + let large_answer = "long answer ".repeat(/*n*/ 200); + chat.bottom_pane.handle_paste(large_answer.clone()); + handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None); + assert_eq!( + chat.bottom_pane.composer_text_with_pending(), + format!( + "$tool existing draft\nOver-ear\nUnder 250\n{}", + large_answer.trim() + ) + ); + assert_eq!(chat.bottom_pane.composer_pending_pastes().len(), 1); + insta::assert_snapshot!( + "question_turn_end_large_draft", + normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)) + ); +} + +#[tokio::test] +async fn question_turn_end_recovers_collapsed_drafts_on_completion_and_failure() { + for status in [AppServerTurnStatus::Completed, AppServerTurnStatus::Failed] { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false); + handle_turn_started(&mut chat, "turn"); + questions(&mut chat, "question"); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + // Leave typing buffered, then collapse before completion. + for ch in "answer".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT)); + for ch in "main".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + if status == AppServerTurnStatus::Failed { + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued prompt").into()); + handle_error(&mut chat, "Turn failed", /*codex_error_info*/ None); + assert!(matches!(ops.try_recv().unwrap(), Op::UserTurn { .. })); + assert_eq!( + chat.capture_thread_input_state() + .unwrap() + .composer + .unwrap() + .text, + "main\nanswer" + ); + } + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: app_server_turn( + "turn", + status.clone(), + /*duration_ms*/ None, + (status == AppServerTurnStatus::Failed).then(|| AppServerTurnError { + misalignment: None, + message: "Turn failed".into(), + codex_error_info: None, + additional_details: None, + }), + ), + }), + /*replay_kind*/ None, + ); + assert_recovered_draft(&mut chat, "main\nanswer"); + assert!(ops.try_recv().is_err()); + } +} + +#[tokio::test] +async fn question_turn_end_recovers_after_interruption_restores_queued_input() { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn"); + chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false); + chat.bottom_pane + .set_composer_text("main draft".into(), Vec::new(), Vec::new()); + questions(&mut chat, "question"); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.handle_paste("answer".into()); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT)); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('+'))); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued prompt").into()); + chat.input_queue + .pending_steers + .push_back(pending_steer("pending steer")); + handle_turn_interrupted(&mut chat, "turn"); + assert_recovered_draft( + &mut chat, + "pending steer\nqueued prompt\nmain draft+\nanswer", + ); + assert!(!chat.has_queued_follow_up_messages()); + assert!(ops.try_recv().is_err()); +} + +fn assert_recovered_draft(chat: &mut ChatWidget, text: &str) { + assert_eq!( + ( + chat.bottom_pane.composer_text(), + chat.bottom_pane.question_editor().unanswered_count() + ), + (text.into(), 0) + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_large_draft.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_large_draft.snap new file mode 100644 index 0000000000..208cc508f3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_large_draft.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/question_turn_end_tests.rs +assertion_line: 86 +expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))" +--- +› $tool existing draft + Over-ear + Under 250 + [Pasted Content 2399 chars] + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_recovered_composer.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_recovered_composer.snap new file mode 100644 index 0000000000..6288a8931f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__question_turn_end_tests__question_turn_end_recovered_composer.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/question_turn_end_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› $tool existing draft + Over-ear + Under 250 + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index d473394efe..c13da80028 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -2,6 +2,7 @@ //! //! This module owns task start/completion state, runtime metrics, plan updates, //! and completion metadata rendering. +//! Terminal errors retain live question drafts across queued input delivery. use super::*; @@ -469,12 +470,19 @@ impl ChatWidget { message: String, codex_error_info: Option, ) { - if codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) { - self.on_misalignment_policy_violation(); - } else if codex_error_info + if codex_error_info .as_ref() .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) { + return; + } + let question_drafts = if self.thread_usage.replaying_turn_completion { + None + } else { + self.bottom_pane.take_question_drafts() + }; + if codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) { + self.on_misalignment_policy_violation(); } else if codex_error_info .as_ref() .is_some_and(is_app_server_cyber_policy_error) @@ -506,6 +514,13 @@ impl ChatWidget { } else { self.on_error(message); } + if let Some(drafts) = question_drafts + && !self.has_misalignment_policy_violation() + { + self.bottom_pane.append_question_drafts(&drafts); + self.refresh_pending_input_preview(); + self.request_redraw(); + } } pub(super) fn on_warning(&mut self, message: impl Into) {