From 1b81365926670763246bdf791a5758b2b3264a9b Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 15 Jun 2026 11:49:19 -0700 Subject: [PATCH] Add request user input auto-resolution timer (#28235) ## Summary - Add TUI auto-resolution handling for `request_user_input` prompts when `autoResolutionMs` is present. - Use a 60s hidden grace period followed by a 60s visible countdown, then submit an empty answer response if the user does not interact. - Snooze auto-resolution on key or paste interaction and add snapshot/test coverage for the countdown UI. ## Notes - The TUI currently treats `autoResolutionMs` as an enable signal and intentionally does not use the provided duration value for the countdown policy. ### Auto resolution https://github.com/user-attachments/assets/5323152f-2ece-4aba-b75d-c32aa776f544 ### Snooze after interaction https://github.com/user-attachments/assets/100d54c4-3a41-4c6c-9c07-cd28075a0d62 --- .../tui/src/bottom_pane/bottom_pane_view.rs | 9 + codex-rs/tui/src/bottom_pane/mod.rs | 16 + .../src/bottom_pane/request_user_input/mod.rs | 402 +++++++++++++++++- .../bottom_pane/request_user_input/render.rs | 24 +- ..._user_input_auto_resolution_countdown.snap | 14 + 5 files changed, 447 insertions(+), 18 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 7538d76ca..a246c05e5 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -4,6 +4,7 @@ use crate::bottom_pane::McpServerElicitationFormRequest; use crate::render::renderable::Renderable; use codex_app_server_protocol::ToolRequestUserInputParams; use crossterm::event::KeyEvent; +use std::time::Instant; use super::CancellationEvent; @@ -88,6 +89,14 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Process time-based state immediately before rendering. + /// + /// Return true when state changed and the bottom pane should redraw or + /// complete the active view. + fn pre_draw_tick(&mut self, _now: Instant) -> bool { + false + } + /// Try to handle approval request; return the original value if not /// consumed. fn try_consume_approval_request( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a932c6c5c..f6446f543 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -734,9 +734,25 @@ impl BottomPane { fn pre_draw_tick_at(&mut self, now: Instant) { self.composer.sync_popups(); self.maybe_show_delayed_approval_requests_at(now); + self.tick_active_view(now); self.schedule_active_view_frame(); } + fn tick_active_view(&mut self, now: Instant) { + let Some(view) = self.view_stack.last_mut() else { + return; + }; + let needs_redraw = view.pre_draw_tick(now); + let view_complete = view.is_complete(); + if view_complete { + self.view_stack.clear(); + self.on_active_view_complete(); + } + if needs_redraw || view_complete { + self.request_redraw(); + } + } + fn schedule_active_view_frame(&self) { if let Some(delay) = self .active_view() diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 02a807423..acbf61568 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use crate::app::app_server_requests::ResolvedAppServerRequest; use crossterm::event::KeyCode; @@ -61,6 +63,8 @@ const UNANSWERED_CONFIRM_GO_BACK_DESC: &str = "Return to the first unanswered qu const UNANSWERED_CONFIRM_SUBMIT: &str = "Proceed"; const UNANSWERED_CONFIRM_SUBMIT_DESC_SINGULAR: &str = "question"; const UNANSWERED_CONFIRM_SUBMIT_DESC_PLURAL: &str = "questions"; +const AUTO_RESOLUTION_HIDDEN_GRACE: Duration = Duration::from_secs(/*secs*/ 60); +const AUTO_RESOLUTION_VISIBLE_COUNTDOWN: Duration = Duration::from_secs(/*secs*/ 60); #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Focus { @@ -68,6 +72,27 @@ enum Focus { Notes, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AutoResolutionTiming { + Disabled, + HiddenGrace { remaining: Duration }, + VisibleCountdown { remaining: Duration }, + Due, +} + +fn format_auto_resolution_remaining(remaining: Duration) -> String { + let mut seconds = remaining.as_secs(); + if remaining.subsec_nanos() > 0 { + seconds = seconds.saturating_add(1); + } + if seconds < 60 { + return format!("{seconds}s"); + } + let minutes = seconds / 60; + let seconds = seconds % 60; + format!("{minutes}m {seconds:02}s") +} + #[derive(Default, Clone, PartialEq)] struct ComposerDraft { text: String, @@ -142,6 +167,8 @@ pub(crate) struct RequestUserInputOverlay { done: bool, pending_submission_draft: Option, confirm_unanswered: Option, + request_started_at: Instant, + auto_resolution_snoozed: bool, composer_submit_keys: Vec, interrupt_turn_keys: Vec, list_keymap: ListKeymap, @@ -198,6 +225,8 @@ impl RequestUserInputOverlay { done: false, pending_submission_draft: None, confirm_unanswered: None, + request_started_at: Instant::now(), + auto_resolution_snoozed: false, composer_submit_keys: keymap.composer.submit.clone(), interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), list_keymap: keymap.list, @@ -230,9 +259,11 @@ impl RequestUserInputOverlay { self.request.questions.len() } - fn advance_queue_or_complete(&mut self) { + fn advance_queue_or_complete_at(&mut self, now: Instant) { if let Some(next) = self.queue.pop_front() { self.request = next; + self.request_started_at = now; + self.auto_resolution_snoozed = false; self.reset_for_request(); self.ensure_focus_available(); self.restore_current_draft(); @@ -241,6 +272,84 @@ impl RequestUserInputOverlay { } } + fn snooze_auto_resolution(&mut self) { + if self.request.auto_resolution_ms.is_some() { + self.auto_resolution_snoozed = true; + } + } + + fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming { + // The TUI currently treats autoResolutionMs as an enable signal. The + // model-provided duration value is reserved for future runtime policy. + if self.request.auto_resolution_ms.is_none() || self.auto_resolution_snoozed { + return AutoResolutionTiming::Disabled; + } + + let elapsed = now.saturating_duration_since(self.request_started_at); + if elapsed < AUTO_RESOLUTION_HIDDEN_GRACE { + return AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE.saturating_sub(elapsed), + }; + } + let visible_elapsed = elapsed.saturating_sub(AUTO_RESOLUTION_HIDDEN_GRACE); + if visible_elapsed < AUTO_RESOLUTION_VISIBLE_COUNTDOWN { + return AutoResolutionTiming::VisibleCountdown { + remaining: AUTO_RESOLUTION_VISIBLE_COUNTDOWN.saturating_sub(visible_elapsed), + }; + } + AutoResolutionTiming::Due + } + + fn auto_resolution_next_frame_delay_at(&self, now: Instant) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::Disabled => None, + AutoResolutionTiming::HiddenGrace { remaining } => Some(remaining), + AutoResolutionTiming::VisibleCountdown { remaining } => { + Some(remaining.min(Duration::from_secs(/*secs*/ 1))) + } + AutoResolutionTiming::Due => Some(Duration::ZERO), + } + } + + fn maybe_auto_resolve_at(&mut self, now: Instant) -> bool { + if !matches!( + self.auto_resolution_timing_at(now), + AutoResolutionTiming::Due + ) { + return false; + } + self.submit_empty_auto_resolution(now); + true + } + + fn auto_resolution_countdown_text_at(&self, now: Instant) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::VisibleCountdown { remaining } => Some(format!( + "auto-resolves in {}", + format_auto_resolution_remaining(remaining) + )), + AutoResolutionTiming::Disabled + | AutoResolutionTiming::HiddenGrace { .. } + | AutoResolutionTiming::Due => None, + } + } + + pub(super) fn progress_prefix_text(&self) -> String { + if self.question_count() > 0 { + let idx = self.current_index() + 1; + let total = self.question_count(); + let base = format!("Question {idx}/{total}"); + let unanswered = self.unanswered_count(); + if unanswered > 0 { + format!("{base} ({unanswered} unanswered)") + } else { + base + } + } else { + "No questions".to_string() + } + } + fn has_options(&self) -> bool { self.current_question() .and_then(|question| question.options.as_ref()) @@ -814,7 +923,26 @@ impl RequestUserInputOverlay { interrupted: false, }, ))); - self.advance_queue_or_complete(); + self.advance_queue_or_complete_at(Instant::now()); + } + + fn submit_empty_auto_resolution(&mut self, now: Instant) { + self.confirm_unanswered = None; + let answers: HashMap = HashMap::new(); + self.app_event_tx.user_input_answer( + self.request.turn_id.clone(), + ToolRequestUserInputResponse { + answers: answers.clone(), + }, + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::RequestUserInputResultCell { + questions: self.request.questions.clone(), + answers, + interrupted: false, + }, + ))); + self.advance_queue_or_complete_at(now); } fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool { @@ -826,7 +954,7 @@ impl RequestUserInputOverlay { self.queue .retain(|queued_request| queued_request.item_id != *call_id); if self.request.item_id == *call_id { - self.advance_queue_or_complete(); + self.advance_queue_or_complete_at(Instant::now()); return true; } @@ -1060,6 +1188,8 @@ impl BottomPaneView for RequestUserInputOverlay { return; } + self.snooze_auto_resolution(); + if self.confirm_unanswered_active() { self.handle_confirm_unanswered_key_event(key_event); return; @@ -1324,6 +1454,7 @@ impl BottomPaneView for RequestUserInputOverlay { if pasted.is_empty() { return false; } + self.snooze_auto_resolution(); if matches!(self.focus, Focus::Options) { // Treat pastes the same as typing: switch into notes. self.focus = Focus::Notes; @@ -1343,6 +1474,14 @@ impl BottomPaneView for RequestUserInputOverlay { self.composer.is_in_paste_burst() } + fn pre_draw_tick(&mut self, now: Instant) -> bool { + self.maybe_auto_resolve_at(now) + } + + fn next_frame_delay(&self) -> Option { + self.auto_resolution_next_frame_delay_at(Instant::now()) + } + fn try_consume_user_input_request( &mut self, request: ToolRequestUserInputParams, @@ -1365,7 +1504,9 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + use ratatui::style::Color; use std::collections::HashMap; + use std::time::Instant; use tokio::sync::mpsc::unbounded_channel; use unicode_width::UnicodeWidthStr; @@ -1542,6 +1683,15 @@ mod tests { } } + fn request_event_with_auto_resolution( + turn_id: &str, + questions: Vec, + ) -> ToolRequestUserInputParams { + let mut request = request_event(turn_id, questions); + request.auto_resolution_ms = Some(60_000); + request + } + fn snapshot_buffer(buf: &Buffer) -> String { let mut lines = Vec::new(); for y in 0..buf.area().height { @@ -1555,8 +1705,12 @@ mod tests { } fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String { + render_snapshot_at(overlay, area, Instant::now()) + } + + fn render_snapshot_at(overlay: &RequestUserInputOverlay, area: Rect, now: Instant) -> String { let mut buf = Buffer::empty(area); - overlay.render(area, &mut buf); + overlay.render_ui_at(area, &mut buf, now); snapshot_buffer(&buf) } @@ -1617,6 +1771,246 @@ mod tests { expect_interrupt_only(&mut rx); } + #[test] + fn auto_resolution_absent_has_no_timer() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::Disabled + ); + assert_eq!(overlay.auto_resolution_next_frame_delay_at(now), None); + assert_eq!(overlay.auto_resolution_countdown_text_at(now), None); + } + + #[test] + fn auto_resolution_hides_timer_during_grace_period() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now; + + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + assert_eq!( + overlay.auto_resolution_next_frame_delay_at(now), + Some(AUTO_RESOLUTION_HIDDEN_GRACE) + ); + assert!( + !render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now).contains("auto-resolves") + ); + } + + #[test] + fn auto_resolution_visible_countdown_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![ + question_with_options("q1", "First"), + question_with_options("q2", "Second"), + question_with_options("q3", "Third"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + insta::assert_snapshot!( + "request_user_input_auto_resolution_countdown", + render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now) + ); + } + + #[test] + fn auto_resolution_visible_countdown_is_red() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + let area = Rect::new(0, 0, 120, 16); + let mut buf = Buffer::empty(area); + + overlay.render_ui_at(area, &mut buf, now); + + let rendered = snapshot_buffer(&buf); + let progress_line = rendered.lines().nth(1).expect("expected progress line"); + let countdown = "auto-resolves in 1m 00s"; + let countdown_byte_idx = progress_line + .find(countdown) + .expect("expected countdown in progress line"); + let countdown_x = progress_line[..countdown_byte_idx].width(); + for offset in 0..countdown.width() { + assert_eq!( + buf[((countdown_x + offset) as u16, 1)].style().fg, + Some(Color::Red) + ); + } + let prefix_byte_idx = progress_line + .find("Question") + .expect("expected question prefix in progress line"); + let prefix_x = progress_line[..prefix_byte_idx].width(); + assert_ne!(buf[(prefix_x as u16, 1)].style().fg, Some(Color::Red)); + } + + #[test] + fn auto_resolution_expiry_emits_empty_answer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - total_timeout; + + assert!(overlay.pre_draw_tick(now)); + assert!(overlay.done); + + let event = rx.try_recv().expect("expected UserInputAnswer event"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + panic!("expected UserInputAnswer event"); + }; + assert_eq!(id, "turn-1"); + assert_eq!(response.answers, HashMap::new()); + + let event = rx.try_recv().expect("expected history cell event"); + assert!( + matches!(event, AppEvent::InsertHistoryCell(_)), + "expected history cell event" + ); + } + + #[test] + fn auto_resolution_key_interaction_snoozes_timer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + + assert_eq!( + overlay.auto_resolution_timing_at(now + total_timeout), + AutoResolutionTiming::Disabled + ); + assert!(!overlay.pre_draw_tick(now + total_timeout)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn auto_resolution_paste_interaction_snoozes_timer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + assert!(overlay.handle_paste("notes".to_string())); + + assert_eq!( + overlay.auto_resolution_timing_at(now + total_timeout), + AutoResolutionTiming::Disabled + ); + assert!(!overlay.pre_draw_tick(now + total_timeout)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn auto_resolution_resets_for_queued_request() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(request_event_with_auto_resolution( + "turn-2", + vec![question_with_options("q2", "Second")], + )); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - total_timeout; + + assert!(overlay.pre_draw_tick(now)); + + assert_eq!(overlay.request.turn_id, "turn-2"); + assert!(!overlay.auto_resolution_snoozed); + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + assert!(!overlay.done); + assert!(rx.try_recv().is_ok()); + } + #[test] fn resolved_request_dismisses_overlay_without_emitting_events() { let (tx, mut rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index eeda76357..773f9548b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -6,6 +6,7 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use std::borrow::Cow; +use std::time::Instant; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; @@ -105,7 +106,7 @@ impl Renderable for RequestUserInputOverlay { } fn render(&self, area: Rect, buf: &mut Buffer) { - self.render_ui(area, buf); + self.render_ui_at(area, buf, Instant::now()); } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { @@ -244,8 +245,7 @@ impl RequestUserInputOverlay { } } - /// Render the full request-user-input overlay. - pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) { + pub(super) fn render_ui_at(&self, area: Rect, buf: &mut Buffer, now: Instant) { if area.width == 0 || area.height == 0 { return; } @@ -261,20 +261,16 @@ impl RequestUserInputOverlay { } let sections = self.layout_sections(content_area); let notes_visible = self.notes_ui_visible(); - let unanswered = self.unanswered_count(); // Progress header keeps the user oriented across multiple questions. - let progress_line = if self.question_count() > 0 { - let idx = self.current_index() + 1; - let total = self.question_count(); - let base = format!("Question {idx}/{total}"); - if unanswered > 0 { - Line::from(format!("{base} ({unanswered} unanswered)").dim()) - } else { - Line::from(base.dim()) - } + let progress_line = if let Some(countdown) = self.auto_resolution_countdown_text_at(now) { + Line::from(vec![ + self.progress_prefix_text().dim(), + " · ".dim(), + countdown.red(), + ]) } else { - Line::from("No questions".dim()) + Line::from(self.progress_prefix_text().dim()) }; Paragraph::new(progress_line).render(sections.progress_area, buf); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap new file mode 100644 index 000000000..509bf4067 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +assertion_line: 1854 +expression: "render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now)" +--- + + Question 1/3 (3 unanswered) · auto-resolves in 1m 00s + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt