mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
71633f8b8f
commit
1b81365926
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+14
@@ -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
|
||||
Reference in New Issue
Block a user