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
This commit is contained in:
Shijie Rao
2026-06-15 11:49:19 -07:00
committed by GitHub
Unverified
parent 71633f8b8f
commit 1b81365926
5 changed files with 447 additions and 18 deletions
@@ -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(
+16
View File
@@ -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()
@@ -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<ComposerDraft>,
confirm_unanswered: Option<ScrollState>,
request_started_at: Instant,
auto_resolution_snoozed: bool,
composer_submit_keys: Vec<KeyBinding>,
interrupt_turn_keys: Vec<KeyBinding>,
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<Duration> {
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<String> {
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<String, ToolRequestUserInputAnswer> = 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<Duration> {
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<ToolRequestUserInputQuestion>,
) -> 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();
@@ -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);
@@ -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