diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index c866c288c0..c5cbaf4a90 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -56,7 +56,19 @@ pub(super) struct ThreadEventStore { } impl ThreadEventStore { - pub(super) fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool { + pub(super) fn event_survives_session_refresh(event: &mut ThreadBufferedEvent) -> bool { + if let ThreadBufferedEvent::Notification(notification) = event + && let ServerNotification::ItemCompleted(notification) = notification.as_mut() + && let ThreadItem::AgentMessage { + questions: Some(_), + text, + .. + } = &mut notification.item + { + // Refreshed turns contain the text; retain only the live question state. + text.clear(); + return true; + } match event { ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::FeedbackSubmission(_) => true, ThreadBufferedEvent::Notification(notification) => matches!( @@ -104,7 +116,7 @@ impl ThreadEventStore { } pub(super) fn rebase_buffer_after_session_refresh(&mut self) { - self.buffer.retain(Self::event_survives_session_refresh); + self.buffer.retain_mut(Self::event_survives_session_refresh); self.buffered_agent_message_delta_bytes = 0; } @@ -721,6 +733,28 @@ mod tests { assert_eq!(store.has_pending_thread_approvals(), false); } + #[test] + fn refresh_retains_live_questions_without_replaying_their_text() { + let mut expected = serde_json::json!({ + "method": "item/completed", "params": { + "threadId": "thread", "turnId": "turn", "completedAtMs": 0, + "item": { + "type": "agentMessage", "id": "question", "text": "already in the snapshot", + "phase": null, "memoryCitation": null, "delivery": null, + "questions": [{"title": "Which way?", "options": null}] + } + } + }); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.push_notification(serde_json::from_value(expected.clone()).unwrap()); + store.rebase_buffer_after_session_refresh(); + expected["params"]["item"]["text"] = serde_json::json!(""); + let ThreadBufferedEvent::Notification(actual) = &store.snapshot().events[0] else { + panic!("missing live question"); + }; + assert_eq!(serde_json::to_value(actual).unwrap(), expected); + } + #[test] fn thread_event_store_rebase_preserves_hook_notifications() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 73dce0c45a..57e106f69b 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1477,7 +1477,7 @@ impl App { snapshot.turns = turns; snapshot .events - .retain(ThreadEventStore::event_survives_session_refresh); + .retain_mut(ThreadEventStore::event_survives_session_refresh); } /// Opens the `/subagents` picker after refreshing cached labels for known threads. diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs index 2c815ed5d3..6d5571f1fc 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/input.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -161,6 +161,7 @@ impl BottomPaneView for AsyncQuestions { if key.kind == KeyEventKind::Release || self.is_complete() { return; } + self.snooze_auto_resolution(); if self.handles_key_as_editing(key) { self.edit(key); return; @@ -287,6 +288,7 @@ impl BottomPaneView for AsyncQuestions { if self.is_complete() || text.is_empty() { return false; } + self.snooze_auto_resolution(); if !self.focus_is_notes() { self.focus_other(); } @@ -299,8 +301,17 @@ impl BottomPaneView for AsyncQuestions { fn is_in_paste_burst(&self) -> bool { self.composer.is_in_paste_burst() } - fn next_frame_delay(&self) -> Option { - self.composer.footer_flash_delay() + self.timer_remaining(Instant::now()) + .map(|remaining| { + if remaining > Duration::from_secs(20) { + remaining - Duration::from_secs(20) + } else { + remaining.min(Duration::from_secs(1)) + } + }) + .into_iter() + .chain(self.composer.footer_flash_delay()) + .min() } } 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 8d7b0c4bfa..0de055ff45 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -21,6 +21,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use std::collections::HashSet; use std::time::Duration; +use std::time::Instant; use unicode_width::UnicodeWidthStr; mod input; @@ -34,16 +35,19 @@ pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2; #[derive(Debug, Clone, PartialEq)] struct PendingQuestion { + message_id: String, question: AsyncUserInputQuestion, options_state: ScrollState, draft: ComposerDraft, + expires_at: Option, } /// Locally retained async questions; replayed message IDs cannot resurrect handled answers. #[derive(Debug, Clone, Default, PartialEq)] -struct QuestionState { +pub(crate) struct QuestionState { pending: Vec, current_idx: usize, + expanded: bool, seen_ids: HashSet, } @@ -56,6 +60,7 @@ pub(crate) struct AsyncQuestions { app_event_tx: AppEventSender, state: QuestionState, pub(crate) expanded: bool, + pub(crate) has_queued_messages: bool, pub(crate) delivery_enabled: bool, pub(crate) submission: Option, visible_options: std::cell::Cell<(usize, usize)>, @@ -80,7 +85,10 @@ impl AsyncQuestions { enhanced_keys_supported, "Type your answer".into(), disable_paste_burst, - ChatComposerConfig::plain_text(), + ChatComposerConfig { + reset_vim_on_submission: false, + ..ChatComposerConfig::plain_text() + }, ); composer.set_keymap_bindings(&keymap); composer.set_footer_hint_override(Some(Vec::new())); @@ -88,6 +96,7 @@ impl AsyncQuestions { app_event_tx, state: QuestionState::default(), expanded: false, + has_queued_messages: false, delivery_enabled: true, submission: None, visible_options: std::cell::Cell::new((0, 0)), diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs index 094de5fbb0..59415de15c 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/render.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -210,6 +210,8 @@ impl AsyncQuestions { } let next = if self.state.current_idx + 1 < self.state.pending.len() { Some("next question") + } else if self.has_queued_messages { + Some("queued messages") } else { None }; diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap new file mode 100644 index 0000000000..58e4bce628 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + 2 of 2 + Second + + 11. Option 11 + 12. Option 12 + › 13. earlier answer + reverse-i-search: e enter accept · esc + + enter submit ctrl + ] skip option 13/13 + ⌥ + ↓ prev question 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 a25f7772c7..d7796c7549 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state.rs @@ -11,6 +11,7 @@ impl AsyncQuestions { return; } let was_empty = self.state.pending.is_empty(); + let expires_at = (!self.expanded).then(|| Instant::now() + Duration::from_secs(30)); self.state.pending.extend(questions.iter().map(|question| { // Bound work before cloning or wrapping model-authored suggestions. let question = AsyncUserInputQuestion { @@ -31,9 +32,11 @@ impl AsyncQuestions { let mut options_state = ScrollState::new(); options_state.selected_idx = has_options.then_some(0); PendingQuestion { + message_id: message_id.into(), question, options_state, draft: ComposerDraft::default(), + expires_at, } })); if was_empty { @@ -45,6 +48,9 @@ impl AsyncQuestions { pub(crate) fn set_expanded(&mut self, expanded: bool) { self.save_current_draft(); self.expanded = expanded && !self.state.pending.is_empty(); + if self.expanded { + self.snooze_auto_resolution(); + } } pub(crate) fn navigate(&mut self, forward: bool) -> bool { @@ -63,6 +69,27 @@ impl AsyncQuestions { true } + pub(super) fn snooze_auto_resolution(&mut self) { + for question in &mut self.state.pending { + question.expires_at = None; + } + } + + pub(super) fn timer_remaining(&self, now: Instant) -> Option { + self.state + .pending + .iter() + .filter_map(|question| question.expires_at?.checked_duration_since(now)) + .filter(|remaining| !remaining.is_zero()) + .min() + } + + pub(crate) fn countdown(&self, now: Instant) -> Option { + self.timer_remaining(now) + .filter(|remaining| *remaining <= Duration::from_secs(20)) + .map(|remaining| format!("{}s", remaining.as_secs_f64().ceil() as u64)) + } + pub(super) fn go_next_or_submit(&mut self) { self.save_current_draft(); if !self.delivery_enabled { @@ -83,7 +110,7 @@ impl AsyncQuestions { if !self.focus_is_notes() && !(first..first + count).contains(&index) { self.composer.show_footer_flash( "Expand terminal to read the entire option".into(), - std::time::Duration::from_secs(5), + Duration::from_secs(5), ); return; } @@ -98,7 +125,7 @@ impl AsyncQuestions { if text.chars().count() > limit { self.composer.show_footer_flash( format!("Answer too long; limit {limit} characters").into(), - std::time::Duration::from_secs(5), + Duration::from_secs(5), ); } else if !text.is_empty() { self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}"))); @@ -119,4 +146,28 @@ impl AsyncQuestions { self.restore_current_draft(); self.composer.reset_vim_mode(); } + + pub(crate) fn capture(&mut self) -> QuestionState { + self.composer.cancel_history_search(); + self.save_current_draft(); + self.state.expanded = self.expanded; + self.state.clone() + } + + pub(crate) fn restore(&mut self, saved: QuestionState) { + self.visible_options.set((0, 0)); + let incoming = std::mem::replace(&mut self.state, saved); + self.state.pending.extend( + incoming + .pending + .into_iter() + .filter(|question| !self.state.seen_ids.contains(&question.message_id)), + ); + self.state.seen_ids.extend(incoming.seen_ids); + self.expanded = self.state.expanded && !self.state.pending.is_empty(); + if self.expanded { + self.snooze_auto_resolution(); + } + self.restore_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 692b60de4e..d9929d89e3 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,27 @@ fn editor() -> AsyncQuestions { editor } +#[test] +fn replay_preserves_drafts_and_does_not_reopen_handled_questions() { + let mut original = editor(); + original.set_expanded(/*expanded*/ true); + original.accept_answer(); + original.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + let saved = original.capture(); + for replay_first in [false, true] { + let mut restored = editor(); + if !replay_first { + restored.state = QuestionState::default(); + } + restored.restore(saved.clone()); + restored.append("message", &[question("First", /*options*/ None)]); + assert_eq!(restored.capture(), saved); + restored.accept_answer(); + restored.append("message", &[question("First", /*options*/ None)]); + assert_eq!(restored.unanswered_count(), 0); + } +} + #[test] fn arrival_preserves_vim_undo_and_navigation_flushes_buffered_input() { let mut editor = editor(); @@ -174,6 +195,33 @@ fn question_choice_remaps_paging_and_inline_feedback() { insta::assert_snapshot!("question_remapped_choice_footer", buffer_text(&buffer)); } +#[test] +fn question_inline_history_search() { + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.state.pending[1].question.options = + Some((1..=12).map(|i| format!("Option {i}")).collect()); + editor.select_option(/*index*/ 12); + editor + .composer + .record_replayed_user_message_history(crate::bottom_pane::HistoryEntry::new( + "earlier answer".into(), + )); + editor.handle_paste("original answer".into()); + let saved = editor.capture(); + editor.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('e'))); + let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12); + assert!( + buffer + .content + .iter() + .any(|cell| cell.modifier.contains(Modifier::REVERSED)) + ); + insta::assert_snapshot!("question_inline_history_search", buffer_text(&buffer)); + assert_eq!(editor.capture(), saved); +} + #[test] fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other() { let mut editor = editor(); @@ -328,3 +376,19 @@ fn existing_cross_context_keymaps_load_without_misleading_submit_hints() { assert!(editor.submission.is_none()); } } + +#[test] +fn question_navigation_resets_history_recall() { + let mut editor = editor(); + for text in ["older", "newer"] { + editor.composer.record_replayed_user_message_history( + crate::bottom_pane::HistoryEntry::new(text.into()), + ); + } + editor.handle_key_event(KeyCode::Up.into()); + assert_eq!(editor.composer.current_text(), "newer"); + editor.navigate(/*forward*/ true); + editor.navigate(/*forward*/ false); + editor.handle_key_event(KeyCode::Up.into()); + assert_eq!(editor.composer.current_text(), "newer"); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 29c2145e23..df82fdee71 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -54,14 +54,12 @@ //! mention bindings, but attachments cannot be restored). //! - Local in-session history (full text + text elements + local/remote image attachments). //! -//! When recalling a local entry, the composer rehydrates text elements and both attachment kinds -//! (local image paths + remote image URLs). +//! Plain-text history recall strips images and their placeholders. //! When recalling a persistent entry, encoded task links restore atomic elements and bindings. -//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like -//! history traversal semantics instead of dropping to column 0. -//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the -//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as -//! an editable draft and `Esc` restores the draft that was active when search started. +//! Recall moves the cursor to the end. Question editors copy primary history on recall/search; +//! draft capture cancels previews, and restoration resets traversal. +//! Ctrl+R searches history in the footer and previews matches in the composer. +//! Enter accepts the preview; Esc restores the original draft. //! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions. //! An active edit keeps one separately capped snapshot; canceling does not evict committed history. //! Canceled history previews restore history and active commands; accepting another prompt resets them. @@ -483,6 +481,8 @@ pub(crate) struct ChatComposerConfig { pub(crate) image_paste_enabled: bool, /// Strip leading and trailing whitespace from submissions. pub(crate) trim_submission: bool, + /// Embedded editors reset Vim only when their owner accepts the answer. + pub(crate) reset_vim_on_submission: bool, } impl Default for ChatComposerConfig { @@ -493,6 +493,7 @@ impl Default for ChatComposerConfig { shell_commands_enabled: true, image_paste_enabled: true, trim_submission: true, + reset_vim_on_submission: true, } } } @@ -509,6 +510,7 @@ impl ChatComposerConfig { shell_commands_enabled: false, image_paste_enabled: false, trim_submission: true, + reset_vim_on_submission: true, } } } @@ -1580,18 +1582,7 @@ impl ChatComposer { ); } - /// Replace the entire composer content while restoring mention link targets. - /// - /// Mention popup insertion stores both visible text (for example `$file`) - /// and hidden mention bindings used to resolve the canonical target during - /// submission. Use this method when restoring an interrupted or blocked - /// draft; if callers restore only text and images, mentions can appear - /// intact to users while resolving to the wrong target or dropping on - /// retry. - /// - /// This helper intentionally places the cursor at the start of the restored text. Callers - /// that need end-of-line restore behavior (for example shell-style history recall) should call - /// [`Self::move_cursor_to_end`] after this method. + /// Restore draft content; clear pending input and undo history. The cursor starts at zero. pub(crate) fn set_text_content_with_mention_bindings( &mut self, text: String, @@ -1797,13 +1788,8 @@ impl ChatComposer { } } - /// Rehydrate a history entry into the composer with shell-like cursor placement. - /// - /// This path restores text, elements, images, mention bindings, and pending paste payloads, - /// then moves the cursor to the active mode's history boundary. If a caller reused - /// [`Self::set_text_content_with_mention_bindings`] directly for history recall and forgot the - /// final cursor move, repeated Up/Down would stop navigating history because cursor-gating - /// treats interior positions as normal editing mode. + /// Recall content at its history boundary, preserving pending pastes and omitting images + /// in plain-text editors. fn apply_history_entry(&mut self, entry: HistoryEntry) { let HistoryEntry { text, @@ -1821,6 +1807,20 @@ impl ChatComposer { mention_bindings, ); self.set_pending_pastes(pending_pastes); + if !self.config.image_paste_enabled { + for image in self.attachments.local_images() { + if let Some(element) = self.draft.textarea.text_elements().into_iter().find(|e| { + e.placeholder(self.draft.textarea.text()) == Some(image.placeholder.as_str()) + }) { + let range = element.byte_range; + self.draft + .textarea + .replace_range(range.start..range.end, ""); + } + } + self.attachments = AttachmentState::default(); + } + self.history.record_recalled_text(self.current_text()); self.move_cursor_to_history_entry_end(); } @@ -3203,7 +3203,8 @@ impl ChatComposer { | InputResult::Command(_) | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) - ) { + ) && self.config.reset_vim_on_submission + { self.reset_vim_mode(); } } 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 2800465429..eb348d49c1 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 @@ -32,6 +32,25 @@ impl ChatComposer { self.draft.textarea.enter_vim_insert_mode(); } + /// Refresh at traversal start without resetting recalled entries or pending lookups. + pub(crate) fn copy_history_for_key(&mut self, composer: &Self, key: KeyEvent) { + let recall = self.is_empty() + && !self.history.is_navigating() + && if self.draft.textarea.is_vim_normal_mode() { + self.vim_normal_keymap.move_up.is_pressed(key) + || self.vim_normal_keymap.move_down.is_pressed(key) + } else { + self.editor_keymap.move_up.is_pressed(key) + || self.editor_keymap.move_down.is_pressed(key) + }; + let search = + self.history_search_previous_keys.is_pressed(key) && !self.handles_key_as_editing(key); + if self.history_search.is_none() && (recall || search) { + self.history = composer.history.clone(); + self.history.reset_navigation(); + } + } + /// Give modal editing precedence over a containing view's submit/navigation keys. /// Up/Down remain available to inline choices; j/k keep their Vim editing behavior. pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 836a014d36..6ec2145e7e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -351,6 +351,15 @@ impl ChatComposerHistory { true } + /// Track the displayed text when a composer normalizes a recalled entry. + pub(super) fn record_recalled_text(&mut self, text: String) { + self.last_history_text = Some(text); + } + + pub(super) fn is_navigating(&self) -> bool { + self.history_cursor.is_some() + } + /// Resets normal history navigation so the next Up key resumes from the newest entry. /// /// This also clears any active incremental search, since normal browsing and Ctrl+R search @@ -554,7 +563,7 @@ impl ChatComposerHistory { .map(HistoryEntryResponse::Found) .unwrap_or(HistoryEntryResponse::Ignored); } - self.last_history_text = Some(entry.text.clone()); + self.record_recalled_text(entry.text.clone()); return HistoryEntryResponse::Found(entry); } @@ -788,7 +797,7 @@ impl ChatComposerHistory { fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult { self.history_cursor = Some(offset as isize); - self.last_history_text = Some(entry.text.clone()); + self.record_recalled_text(entry.text.clone()); if let Some(search) = self.search.as_mut() { search.selected_offset = Some(offset); search.record_match(offset, &entry); @@ -817,7 +826,7 @@ impl ChatComposerHistory { let history_match = self.search.as_ref()?.unique_matches[next_index].clone(); self.history_cursor = Some(history_match.offset as isize); - self.last_history_text = Some(history_match.entry.text.clone()); + self.record_recalled_text(history_match.entry.text.clone()); if let Some(search) = self.search.as_mut() { search.select_match(next_index); } @@ -864,7 +873,7 @@ impl ChatComposerHistory { continue; } self.pending_navigation_direction = None; - self.last_history_text = Some(entry.text.clone()); + self.record_recalled_text(entry.text.clone()); return Some(entry); } @@ -1596,7 +1605,7 @@ mod tests { ); history.reset_navigation(); - assert!(history.history_cursor.is_none()); + assert!(!history.is_navigating()); assert!(history.last_history_text.is_none()); assert_eq!( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e720138237..c1cd7db9f3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -55,6 +55,7 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Paragraph; use std::time::Duration; use std::time::Instant; @@ -67,6 +68,9 @@ mod async_questions; mod hook_status; mod mcp_server_elicitation; mod multi_select_picker; +#[cfg(test)] +#[path = "questions_tests.rs"] +mod question_tests; mod questions; mod request_user_input; mod status_line_setup; @@ -89,6 +93,7 @@ pub(crate) use approval_overlay::McpElicitationApprovalRequest; pub(crate) use approval_overlay::PermissionsApprovalRequest; pub(crate) use approval_overlay::format_requested_permissions_rule; pub(crate) use async_questions::AsyncQuestions; +pub(crate) use async_questions::QuestionState; pub(crate) use async_questions::QuestionSubmission; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; @@ -724,6 +729,9 @@ impl BottomPane { if self.view_stack.is_empty() && let Some(questions) = self.questions.as_mut().filter(|q| q.expanded) { + questions + .composer + .copy_history_for_key(&self.composer, key_event); questions.composer.set_task_running(self.is_task_running); questions.handle_key_event(key_event); if records_composer_activity { @@ -1878,29 +1886,28 @@ impl BottomPane { } pub(crate) fn on_history_lookup_response(&mut self, response: HistoryLookupResponse) { - let updated = match response { - HistoryLookupResponse::Entry { - offset, - log_id, - entry, - } => self - .composer - .on_history_entry_response(log_id, offset, entry), - HistoryLookupResponse::Batch { - cursor, - log_id, - entries, - next_older_cursor, - } => { - self.composer - .on_history_batch_response(log_id, cursor, entries, next_older_cursor) - } - HistoryLookupResponse::BatchError { cursor, log_id } => { - self.composer.on_history_batch_error(log_id, cursor) - } - }; + let mut updated = false; + for composer in std::iter::once(&mut self.composer) + .chain(self.questions.iter_mut().map(|q| &mut q.composer)) + { + updated |= match response.clone() { + HistoryLookupResponse::Entry { + offset, + log_id, + entry, + } => composer.on_history_entry_response(log_id, offset, entry), + HistoryLookupResponse::Batch { + cursor, + log_id, + entries, + next_older_cursor, + } => composer.on_history_batch_response(log_id, cursor, entries, next_older_cursor), + HistoryLookupResponse::BatchError { cursor, log_id } => { + composer.on_history_batch_error(log_id, cursor) + } + }; + } if updated { - self.composer.sync_popups(); self.request_redraw(); } } @@ -1990,7 +1997,12 @@ 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() + let has_questions = self + .questions + .as_ref() + .is_some_and(|q| q.unanswered_count() > 0); + let has_pending_input = has_questions + || !self.pending_input_preview.queued_messages.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_widget().is_some() @@ -2009,7 +2021,15 @@ impl BottomPane { } flex.push( /*flex*/ 1, - RenderableItem::Borrowed(&self.pending_input_preview), + if has_questions { + RenderableItem::Owned(Box::new( + pending_input_preview::PendingInputPreviewContent( + &self.pending_input_preview, + ), + )) + } else { + RenderableItem::Borrowed(&self.pending_input_preview) + }, ); let question_editor = self.questions.as_ref().filter(|q| q.expanded); if !has_inline_previews @@ -2018,8 +2038,11 @@ impl BottomPane { { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); } - if let Some(summary) = self.question_summary() { - flex.push(/*flex*/ 0, RenderableItem::Owned(Box::new(summary))); + if let Some(summary) = self.question_summary(Instant::now()) { + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(Paragraph::new(summary))), + ); } let mut flex2 = FlexRenderable::new(); flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); @@ -2170,7 +2193,7 @@ mod tests { snapshot_buffer(&buf) } - fn test_pane(app_event_tx: AppEventSender) -> BottomPane { + pub(super) fn test_pane(app_event_tx: AppEventSender) -> BottomPane { test_pane_with_disable_paste_burst(app_event_tx, /*disable_paste_burst*/ false) } diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index a973172c40..34ea0990c2 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -31,6 +31,11 @@ pub(crate) struct PendingInputPreview { interrupt_binding: Option, } +enum QuestionPresence { + Absent, + Present, +} + const PREVIEW_LINE_LIMIT: usize = 3; impl PendingInputPreview { @@ -76,10 +81,12 @@ impl PendingInputPreview { )); } - fn as_renderable(&self, width: u16) -> Box { + fn as_renderable(&self, width: u16, questions: QuestionPresence) -> Box { + let has_questions = matches!(questions, QuestionPresence::Present); if (self.pending_steers.is_empty() && self.rejected_steers.is_empty() - && self.queued_messages.is_empty()) + && self.queued_messages.is_empty() + && !has_questions) || width < 4 { return Box::new(()); @@ -136,7 +143,7 @@ impl PendingInputPreview { } } - if !self.queued_messages.is_empty() { + if !self.queued_messages.is_empty() || has_questions { if !lines.is_empty() { lines.push(Line::from("")); } @@ -161,6 +168,7 @@ impl PendingInputPreview { } if !self.queued_messages.is_empty() + && !has_questions && let Some(edit_binding) = self.edit_binding { lines.push( @@ -183,11 +191,30 @@ impl Renderable for PendingInputPreview { return; } - self.as_renderable(area.width).render(area, buf); + self.as_renderable(area.width, QuestionPresence::Absent) + .render(area, buf); } fn desired_height(&self, width: u16) -> u16 { - self.as_renderable(width).desired_height(width) + self.as_renderable(width, QuestionPresence::Absent) + .desired_height(width) + } +} + +/// Pending questions keep the follow-up group visible and provide its navigation hint. +pub(super) struct PendingInputPreviewContent<'a>(pub(super) &'a PendingInputPreview); + +impl Renderable for PendingInputPreviewContent<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.0 + .as_renderable(area.width, QuestionPresence::Present) + .render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.0 + .as_renderable(width, QuestionPresence::Present) + .desired_height(width) } } diff --git a/codex-rs/tui/src/bottom_pane/questions.rs b/codex-rs/tui/src/bottom_pane/questions.rs index 7bb9f97696..1d375baa12 100644 --- a/codex-rs/tui/src/bottom_pane/questions.rs +++ b/codex-rs/tui/src/bottom_pane/questions.rs @@ -28,25 +28,35 @@ impl BottomPane { }) } - pub(super) fn question_summary(&self) -> Option> { + pub(crate) fn restore_questions(&mut self, state: Option) { + if let Some(state) = state { + self.question_editor().restore(state); + } + self.request_redraw(); + } + + pub(super) fn question_summary(&self, now: Instant) -> Option>> { let questions = self .questions .as_ref() .filter(|q| !q.expanded && q.unanswered_count() > 0)?; let count = questions.unanswered_count(); - let hint = self - .pending_input_preview - .edit_binding - .map(|key| format!(" · {} to answer", key.display_label())) + let countdown = questions + .countdown(now) + .map(|text| format!(" · {text}")) .unwrap_or_default(); - Some(Line::from(vec![ + let mut lines = vec![Line::from(vec![ " ? ".dim(), Span::styled( format!("{count} question{}", if count == 1 { "" } else { "s" }), crate::style::accent_style(), ) .bold(), - hint.dim(), - ])) + countdown.dim(), + ])]; + if let Some(binding) = self.pending_input_preview.edit_binding { + lines.push(Line::from(vec![" ".into(), binding.into(), " to answer".into()]).dim()); + } + Some(lines) } } diff --git a/codex-rs/tui/src/bottom_pane/questions_tests.rs b/codex-rs/tui/src/bottom_pane/questions_tests.rs new file mode 100644 index 0000000000..fb3406cc51 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/questions_tests.rs @@ -0,0 +1,52 @@ +//! Question orchestration retains input buffers and renders pending-question summaries. +use super::*; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +#[test] +fn questions_flush_buffered_typing_and_record_activity() { + let (tx, _rx) = unbounded_channel(); + let mut pane = super::tests::test_pane(AppEventSender::new(tx)); + let questions = [codex_protocol::items::AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }]; + pane.push_async_questions("message", &questions); + pane.questions + .as_mut() + .unwrap() + .set_expanded(/*expanded*/ true); + pane.handle_paste("pasted ".into()); + assert!(pane.last_composer_activity_at.is_some()); + pane.last_composer_activity_at = None; + for ch in "answer".chars() { + pane.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(pane.last_composer_activity_at.is_some()); + std::thread::sleep(paste_burst::PasteBurst::recommended_active_flush_delay()); + assert!(pane.flush_paste_burst_if_due()); + assert_eq!( + pane.questions.as_ref().unwrap().composer.current_text(), + "pasted answer" + ); + pane.questions + .as_mut() + .unwrap() + .set_expanded(/*expanded*/ false); + pane.push_async_questions("next", &questions); + let now = Instant::now() + Duration::from_secs(15); + insta::assert_snapshot!( + "question_collapsed_countdown", + pane.question_summary(now) + .unwrap() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); + let editor = pane.questions.as_mut().unwrap(); + assert!(editor.countdown(now + Duration::from_secs(30)).is_none()); + editor.set_expanded(/*expanded*/ true); + editor.append("while-open", &questions); + assert!(editor.countdown(now).is_none()); +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap new file mode 100644 index 0000000000..e2c7d1f56e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/questions_tests.rs +expression: "pane.question_summary(now).unwrap().iter().map(ToString::to_string).collect::>().join(\"\\n\")" +--- + ? 2 questions · 15s + ⌥ + ↑ to answer diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 471dc8644e..82f5560621 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -247,6 +247,10 @@ impl ChatWidget { /// Rebuild and update the bottom-pane pending-input preview. pub(super) fn refresh_pending_input_preview(&mut self) { + let has_queued = self.has_queued_follow_up_messages(); + if let Some(questions) = &mut self.bottom_pane.questions { + questions.has_queued_messages = has_queued; + } let preview = self.input_queue.preview(); self.bottom_pane.set_pending_input_preview( preview.queued_messages, diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index d7e7ae3390..e3a35cf663 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -98,6 +98,11 @@ impl ChatWidget { pending_draft: &mut Option, ) { if self.has_active_view() + || self + .bottom_pane + .questions + .as_ref() + .is_some_and(|q| q.expanded) || self.has_pending_protected_request() || !self.bottom_pane.composer_input_enabled() { @@ -432,7 +437,7 @@ impl ChatWidget { } } - pub(crate) fn capture_thread_input_state(&self) -> Option { + pub(crate) fn capture_thread_input_state(&mut self) -> Option { let draft = self.bottom_pane.composer_draft_snapshot(); let composer = ThreadComposerState { text: draft.text, @@ -443,6 +448,11 @@ impl ChatWidget { pending_pastes: draft.pending_pastes, }; Some(ThreadInputState { + questions: self + .bottom_pane + .questions + .as_deref_mut() + .map(crate::bottom_pane::AsyncQuestions::capture), composer: composer.has_content().then_some(composer), safety_buffering_prompt: self.safety_buffering_prompt.clone(), pending_steers: self.input_queue.pending_steers.clone(), @@ -474,6 +484,7 @@ impl ChatWidget { let restored_task_running = preserve_in_flight_turn && input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { + self.bottom_pane.restore_questions(input_state.questions); self.input_queue.recovered_queue = input_state.recovered_queue; self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index fcd6149226..6f7efeef2c 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -440,6 +440,16 @@ impl ChatWidget { notification: ItemCompletedNotification, replay_kind: Option, ) { + // Buffered live notifications can introduce questions; historical turn replay cannot. + if replay_kind == Some(ReplayKind::ThreadSnapshot) + && let ThreadItem::AgentMessage { + id, + questions: Some(questions), + .. + } = ¬ification.item + { + self.add_async_questions(id, questions); + } match notification.item { item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), item => self.handle_thread_item( diff --git a/codex-rs/tui/src/chatwidget/questions.rs b/codex-rs/tui/src/chatwidget/questions.rs index fe87acbd8d..ab3a9587c6 100644 --- a/codex-rs/tui/src/chatwidget/questions.rs +++ b/codex-rs/tui/src/chatwidget/questions.rs @@ -12,6 +12,7 @@ impl ChatWidget { questions: &[AsyncUserInputQuestion], ) { self.bottom_pane.push_async_questions(message_id, questions); + self.refresh_pending_input_preview(); } pub(super) fn handle_question_key(&mut self, key: KeyEvent) -> bool { @@ -49,7 +50,6 @@ impl ChatWidget { self.request_redraw(); return true; } - return true; } else if forward && self.bottom_pane.no_modal_or_popup_active() && let Some(questions) = self @@ -62,6 +62,19 @@ impl ChatWidget { self.request_redraw(); return true; } + if expanded + && forward + && !self.blocks_direct_input + && let Some(composer) = self.pop_latest_queued_composer_state() + { + if let Some(questions) = &mut self.bottom_pane.questions { + questions.set_expanded(/*expanded*/ false); + } + self.restore_composer_state(composer); + self.refresh_pending_input_preview(); + self.request_redraw(); + return true; + } return expanded; } if !expanded { diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs index 200ceb5bb5..2b7a96db37 100644 --- a/codex-rs/tui/src/chatwidget/reconnect.rs +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -54,6 +54,9 @@ impl ChatWidget { } pub(crate) fn pause_unavailable_thread(&mut self) { + if let Some(questions) = &mut self.bottom_pane.questions { + questions.delivery_enabled = false; + } self.turn_lifecycle .restore_running(/*running*/ false, Instant::now()); self.update_task_running_state(); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d763e044a7..af912ae51f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1756,6 +1756,7 @@ async fn restore_thread_input_state_applies_running_state_policy() { text_elements: Vec::new(), }); let input_state = ThreadInputState { + questions: None, composer: Some(ThreadComposerState { text: "composer draft".to_string(), ..Default::default() @@ -2441,8 +2442,12 @@ async fn reconnect_holds_only_recovered_input_until_manually_edited() { chat.set_queue_autosend_suppressed(/*suppressed*/ false); if let Some(text) = recovered { assert!(!chat.maybe_send_next_queued_input()); - assert_eq!(chat.pop_latest_queued_composer_state().unwrap().text, text); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + assert_eq!(chat.bottom_pane.composer_text(), text); assert_no_submit_op(&mut ops); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(next_submit_op(&mut ops), Op::UserTurn { .. }); + chat.input_queue.user_turn_pending_start = false; } chat.input_queue .queued_user_messages diff --git a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs index 5852a451da..77dd754a72 100644 --- a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs @@ -136,6 +136,12 @@ async fn question_inputs_support_vim_editing_and_search() { } assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded); let normal = render_bottom_popup(&chat, /*width*/ 80); + let editor = chat.bottom_pane.questions.as_mut().unwrap(); + editor.delivery_enabled = false; + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), normal); + let editor = chat.bottom_pane.questions.as_mut().unwrap(); + editor.delivery_enabled = true; for ch in "dw".chars() { chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); } @@ -169,6 +175,101 @@ async fn question_inputs_support_vim_editing_and_search() { } } +#[tokio::test] +async fn question_history_search_uses_updated_bindings() { + for (binding, word) in [("f12", "earlier"), ("ctrl-x r", "persistent")] { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.bottom_pane + .set_history_metadata(thread_id, /*log_id*/ 7, /*entry_count*/ 1); + chat.bottom_pane.record_replayed_user_message_history( + crate::bottom_pane::HistoryEntry::with_pending_and_remote( + "[Image #2]earlier answer".into(), + vec![TextElement::new((0..10).into(), Some("[Image #2]".into()))], + vec![PathBuf::from("/tmp/image.png")], + Vec::new(), + vec!["https://example.com/remote.png".into()], + ), + ); + chat.add_async_questions("message", &questions()); + let config = toml::from_str(&format!( + "[composer]\nhistory_search_previous = '{binding}'" + )) + .unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + chat.apply_keymap_update(config, &keymap); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.record_replayed_user_message_history( + crate::bottom_pane::HistoryEntry::new("newest answer".into()), + ); + for (key, expected) in [ + (KeyCode::Up, "newest answer"), + (KeyCode::Up, "earlier answer"), + (KeyCode::Down, "newest answer"), + ] { + chat.handle_key_event(KeyEvent::from(key)); + let rendered = render_bottom_popup(&chat, /*width*/ 80); + assert!( + rendered.contains(expected), + "{key:?}: expected {expected}, got {rendered}" + ); + } + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + // Chords reach the editor using the dispatch binding resolved by the app. + let (key, modifiers) = keymap.composer.history_search_previous[0].parts(); + chat.handle_key_event(KeyEvent::new(key, modifiers)); + for ch in word.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + if binding != "f12" { + chat.bottom_pane.on_history_lookup_response( + crate::app_event::HistoryLookupResponse::Entry { + offset: 0, + log_id: 7, + entry: Some("persistent answer".into()), + }, + ); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(op_rx.try_recv().is_err()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_answer( + op_rx.try_recv().unwrap(), + &format!("> Which way?\n\n{word} answer"), + ); + } +} + +#[tokio::test] +async fn question_editor_keeps_working_status_and_queued_messages_visible() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.bottom_pane.set_task_running(/*running*/ true); + chat.bottom_pane + .set_composer_text("main draft".into(), Vec::new(), Vec::new()); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued follow-up".to_string()).into()); + chat.refresh_pending_input_preview(); + chat.add_async_questions("message", &questions()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + let open = render_bottom_popup(&chat, /*width*/ 80); + let open = open.split("\n 1 of 2").next().unwrap().trim_end(); + chat.bottom_pane.set_task_running(/*running*/ false); + assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded); + chat.input_queue.queued_user_messages.clear(); + chat.refresh_pending_input_preview(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.bottom_pane.set_status_line_enabled(/*enabled*/ false); + let completed = render_bottom_popup(&chat, /*width*/ 80); + let completed = completed.split("\n\n›").next().unwrap(); + insta::assert_snapshot!( + "questions_with_status_and_queue", + format!("OPEN\n{open}\n\nONLY QUESTIONS\n{completed}") + ); +} + #[tokio::test] async fn async_questions_reject_blank_answers_but_allow_explicit_skip() { for options in [None, Some(vec!["Named".to_string()])] { @@ -274,6 +375,94 @@ async fn question_key_repeats_do_not_consume_another_question() { } } +#[tokio::test] +async fn question_queue_pop_becomes_an_ordinary_composer_draft() { + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + for text in ["older", "newer"] { + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from(text).into()); + } + open_questions(&mut chat, /*options*/ None); + let forward = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); + chat.handle_key_event(forward); + insta::assert_snapshot!( + "last_question_with_queue", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(forward); + assert_eq!(chat.bottom_pane.composer_text(), "newer"); + chat.bottom_pane.handle_paste(" edited".into()); + chat.handle_key_event(forward); + assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded); + assert_eq!(chat.bottom_pane.composer_text(), "newer edited"); + chat.handle_key_event(forward); + assert_eq!(chat.bottom_pane.composer_text(), "older"); + assert!(chat.input_queue.queued_user_messages.is_empty()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_answer(ops.try_recv().unwrap(), "older"); + chat.handle_key_event(forward); + assert!(chat.bottom_pane.questions.as_ref().unwrap().expanded); + assert!( + !chat + .bottom_pane + .questions + .as_ref() + .unwrap() + .has_queued_messages + ); +} + +#[tokio::test] +async fn question_drafts_survive_navigation_and_snapshot_replay() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane + .set_composer_text("main draft".into(), Vec::new(), Vec::new()); + open_questions(&mut chat, /*options*/ None); + chat.bottom_pane.handle_paste("first draft".into()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + chat.bottom_pane.handle_paste("second draft".into()); + let saved = chat.capture_thread_input_state(); + let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + let item = AppServerThreadItem::AgentMessage { + id: "buffered".into(), + text: String::new(), + phase: None, + memory_citation: None, + delivery: None, + questions: Some(vec![question("Buffered?", /*options*/ None)]), + }; + for kind in [ + ReplayKind::ResumeInitialMessages, + ReplayKind::ThreadSnapshot, + ] { + chat.replay_thread_item(item.clone(), "turn".into(), kind); + assert!(chat.bottom_pane.questions.is_none()); + } + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + item, + thread_id: "thread".into(), + turn_id: "turn".into(), + completed_at_ms: 0, + }), + Some(ReplayKind::ThreadSnapshot), + ); + assert_eq!(question_count(&chat), 1); + chat.restore_reconnected_input(saved); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("second draft")); + chat.pause_unavailable_thread(); + chat.handle_question_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL)); + chat.handle_question_key(KeyEvent::from(KeyCode::Enter)); + assert_eq!(question_count(&chat), 3); + assert!(ops.try_recv().is_err()); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT)); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("first draft")); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT)); + assert_eq!(chat.bottom_pane.composer_text(), "main draft"); +} + #[tokio::test] async fn single_question_spacing_with_working_status() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -287,7 +476,7 @@ async fn single_question_spacing_with_working_status() { .iter() .position(|line| line.trim() == "Only question?") .unwrap(); - assert!(rows[question - 2].contains("Working")); + assert!(rows[question - 2].contains("Queued follow-up inputs")); assert!(rows[question - 1].is_empty()); assert_eq!(rows[question + 2].trim(), "A typed answer"); assert!(rows[question + 3].is_empty()); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 117a6522ca..e5b643baa1 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -376,6 +376,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.restore_thread_input_state( Some(ThreadInputState { + questions: None, composer: None, safety_buffering_prompt: None, pending_steers: VecDeque::from([expected_pending.clone()]), diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__freeform_question.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__freeform_question.snap index ce76526a08..b9c75d6b4f 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__freeform_question.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__freeform_question.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs expression: "render_bottom_popup(&chat, 80)" --- +• Queued follow-up inputs + 1 of 2 Which way? diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__last_question_with_queue.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__last_question_with_queue.snap new file mode 100644 index 0000000000..ecbc196c65 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__last_question_with_queue.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests/questions_tests.rs +expression: "render_bottom_popup(&chat, 80)" +--- +• Queued follow-up inputs + ↳ older + ↳ newer + + 2 of 2 + Second? + + › 1. Next + 2. Other + + enter submit ctrl + ] skip ⌥ + ↓ prev question ⌥ + ↑ queued messages diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap index ad8a373fcb..87414206b1 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__named_question.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs expression: "render_bottom_popup(&chat, 80)" --- +• Queued follow-up inputs + What next? › 1. diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__oversized_question.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__oversized_question.snap index 77b0cc3278..b947b63341 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__oversized_question.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__oversized_question.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs expression: "render_bottom_popup(&chat, 80)" --- +• Queued follow-up inputs + What next? Type your answer diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap index 1e3b65e3a9..0f7f959118 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Left.snap @@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)" --- • Queued follow-up inputs ↳ queued - shift + ← edit last queued message 1 of 2 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap index 4c459c6d42..b763a8e4d0 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap @@ -4,7 +4,6 @@ expression: "render_bottom_popup(&chat, 100)" --- • Queued follow-up inputs ↳ queued - ⌥ + ↑ edit last queued message 1 of 4 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap index 194b67103b..edd012bc22 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_other.snap @@ -3,6 +3,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")" --- NORMAL +• Queued follow-up inputs + Which way? 1. Named @@ -12,6 +14,8 @@ NORMAL enter submit ctrl + ] skip ⌥ + ↓ main prompt SEARCH +• Queued follow-up inputs + Which way? 1. Named diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap index 9e0ce2c531..d3d3a11b84 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_vim_single.snap @@ -3,6 +3,8 @@ source: tui/src/chatwidget/tests/questions_tests.rs expression: "format!(\"NORMAL\\n{normal}\\n\\nSEARCH\\n{search}\")" --- NORMAL +• Queued follow-up inputs + Which way? alpha beta @@ -11,6 +13,8 @@ NORMAL enter submit ctrl + ] skip ⌥ + ↓ main prompt SEARCH +• Queued follow-up inputs + Which way? alpha beta diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__questions_with_status_and_queue.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__questions_with_status_and_queue.snap new file mode 100644 index 0000000000..4301031993 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__questions_with_status_and_queue.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests/questions_tests.rs +expression: "format!(\"OPEN\\n{open}\\n\\nONLY QUESTIONS\\n{completed}\")" +--- +OPEN +• Working (0s • esc to interrupt) + +• Queued follow-up inputs + ↳ queued follow-up + +ONLY QUESTIONS +• Queued follow-up inputs + ? 2 questions + ⌥ + ↑ to answer diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__single_question_working_spacing.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__single_question_working_spacing.snap index 5681933858..afab6e15be 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__single_question_working_spacing.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__single_question_working_spacing.snap @@ -4,6 +4,8 @@ expression: rendered --- • Working (0s • esc to interrupt) +• Queued follow-up inputs + Only question? A typed answer diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 88e62358c0..01112aaf74 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -122,6 +122,7 @@ impl ThreadComposerState { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { + pub(crate) questions: Option, pub(super) composer: Option, pub(super) safety_buffering_prompt: Option, pub(crate) pending_steers: VecDeque,