From 54e04f25dbfe342bf84809d1880dbca32cb43cf6 Mon Sep 17 00:00:00 2001 From: riley-oai Date: Tue, 8 Sep 2026 04:31:33 +0000 Subject: [PATCH] Add a TUI user verification prompt component (#43702) ## What changed Add a presentation-only `UserVerificationView` with explicit verify and cancel choices, request details, and a waiting state. Report decisions through a callback, suppress duplicate approvals while waiting, and allow cancellation before or during verification. Dismiss the view when its matching MCP elicitation request resolves. Add a full-screen request details overlay that can be opened without making a decision. Wrap prompt text and long URLs for narrow terminals. The view is not yet connected to incoming verification requests. ## Testing Add tests and snapshots covering prompt and waiting rendering, narrow layouts, long URLs, duplicate approval suppression, cancellation, matching request dismissal, and full-screen inspection and scrolling. GitOrigin-RevId: de1ff80d0defb3707b641e9bfccff9e2b91255ec --- codex-rs/tui/src/app/event_dispatch.rs | 8 + codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 2 + ..._user_verification_full_screen_bottom.snap | 27 ++ ...n__tests__user_verification_long_urls.snap | 21 ++ ...tion__tests__user_verification_narrow.snap | 25 ++ ...tion__tests__user_verification_prompt.snap | 15 + ...ion__tests__user_verification_waiting.snap | 11 + .../tui/src/bottom_pane/user_verification.rs | 302 ++++++++++++++++++ .../bottom_pane/user_verification_tests.rs | 202 ++++++++++++ 10 files changed, 618 insertions(+) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap create mode 100644 codex-rs/tui/src/bottom_pane/user_verification.rs create mode 100644 codex-rs/tui/src/bottom_pane/user_verification_tests.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b77998c8f9..d505940070 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2983,6 +2983,14 @@ impl App { AppEvent::ManageSkillsClosed => { self.chat_widget.handle_manage_skills_closed(); } + AppEvent::FullScreenUserVerificationRequest(request) => { + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_static_with_renderables( + vec![crate::bottom_pane::user_verification::prompt_header(&request)], + "U S E R V E R I F I C A T I O N".to_string(), + self.keymap.pager.clone(), + )); + } AppEvent::FullScreenApprovalRequest(request) => match request { ApprovalRequest::ApplyPatch(request) => { let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 285b7de6d7..7ce594462e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1373,6 +1373,11 @@ pub(crate) enum AppEvent { /// Open the approval popup. FullScreenApprovalRequest(ApprovalRequest), + /// Inspect the complete verification request without deciding it. + FullScreenUserVerificationRequest( + crate::bottom_pane::user_verification::UserVerificationRequest, + ), + /// Open the feedback note entry overlay after the user selects a category. OpenFeedbackNote { category: FeedbackCategory, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 653da8dffa..37ecd4fd03 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -77,6 +77,8 @@ mod status_line_setup; mod status_line_style; mod status_surface_preview; mod title_setup; +#[allow(dead_code)] // Connected by the following verification UI integration. +pub(crate) mod user_verification; mod voice_strip; pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX; pub(crate) use action_required_title::build_action_required_title_text; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap new file mode 100644 index 0000000000..3aac1636d8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 12 }, + content: [ + "/ USER VERIFICATION / / / / / / / / / / ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Final approval details. ", + "───────────────────────────────── 100% ─", + " ↑/↓ to scroll pgup/pgdn to page hom", + " q close ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 8, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap new file mode 100644 index 0000000000..0bb6e75c61 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 30)" +--- + + Approve + https://example.com/produc + tion/release/candidate? + + Server: deployments + + Deploy + https://example.com/produc + tion/critical-release-cand + idate + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or + esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap new file mode 100644 index 0000000000..730037a306 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 20)" +--- + + Production + deployment needs + your approval. + + Server: + deployments + + Approve + deploying the + reviewed + release? + +› 1. Verify and + approve (y) + 2. Cancel this + request (c) + + Press enter to + confirm or esc to + cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap new file mode 100644 index 0000000000..006d65c8aa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 80)" +--- + + Production deployment needs your approval. + + Server: deployments + + Approve deploying the reviewed release? + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap new file mode 100644 index 0000000000..008672beb5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 80)" +--- +Waiting for verification… + +Server: deployments + +Approve deploying the reviewed release? + +Press esc to cancel this request diff --git a/codex-rs/tui/src/bottom_pane/user_verification.rs b/codex-rs/tui/src/bottom_pane/user_verification.rs new file mode 100644 index 0000000000..0e8a48fa60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/user_verification.rs @@ -0,0 +1,302 @@ +//! Presentation-only user-verification approval prompt. +//! +//! This view intentionally does not share the approval overlay state machine. User verification +//! always requires explicit approval. The controller owns the app-server request and its proof; +//! this view only calls its decision callback and displays the pending state. + +use codex_app_server_protocol::RequestId; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; + +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ListSelectionView; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::ViewCompletion; +use crate::bottom_pane::popup_consts::accept_cancel_hint_line; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ApprovalKeymap; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; + +#[derive(Clone, Debug)] +pub(crate) struct UserVerificationRequest { + pub title: String, + pub description: String, + pub thread_label: Option, + pub server_name: String, + pub request_id: RequestId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum UserVerificationDecision { + Verify, + Cancel, +} + +#[derive(Clone)] +struct UserVerificationOption { + label: String, + decision: UserVerificationDecision, + shortcuts: Vec, +} + +#[derive(Clone, Copy)] +enum UserVerificationState { + Prompt, + Waiting, + Complete(ViewCompletion), +} + +pub(crate) struct UserVerificationView { + request: UserVerificationRequest, + on_decision: Box, + list: ListSelectionView, + options: Vec, + state: UserVerificationState, + list_keymap: ListKeymap, + approval_keymap: ApprovalKeymap, + app_event_tx: AppEventSender, +} + +impl UserVerificationView { + pub(crate) fn new( + request: UserVerificationRequest, + app_event_tx: AppEventSender, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + on_decision: Box, + ) -> Self { + let options = user_verification_options(&approval_keymap); + let items = options + .iter() + .map(|option| SelectionItem { + name: option.label.clone(), + display_shortcut: approval_keymap.hint_for_bindings(&option.shortcuts), + dismiss_on_select: false, + ..Default::default() + }) + .collect(); + let header = prompt_header(&request); + let params = SelectionViewParams { + footer_note: Some( + accept_cancel_hint_line( + list_keymap.primary_hint(ListAction::Accept), + "to confirm", + list_keymap.primary_hint(ListAction::Cancel), + "to cancel", + ) + .dim(), + ), + items, + header, + ..Default::default() + }; + Self { + request, + on_decision, + list: ListSelectionView::new(params, app_event_tx.clone(), list_keymap.clone()), + options, + state: UserVerificationState::Prompt, + list_keymap, + approval_keymap, + app_event_tx, + } + } + + fn apply_selection(&mut self, actual_idx: usize) { + if !matches!(self.state, UserVerificationState::Prompt) { + return; + } + match self.options.get(actual_idx).map(|option| option.decision) { + Some(UserVerificationDecision::Verify) => self.start_verification(), + Some(UserVerificationDecision::Cancel) => self.cancel(), + None => {} + } + } + + fn start_verification(&mut self) { + self.state = UserVerificationState::Waiting; + (self.on_decision)(UserVerificationDecision::Verify); + } + + fn cancel(&mut self) { + if matches!(self.state, UserVerificationState::Complete(_)) { + return; + } + (self.on_decision)(UserVerificationDecision::Cancel); + self.state = UserVerificationState::Complete(ViewCompletion::Cancelled); + } + + fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { + if self.approval_keymap.open_fullscreen.is_pressed(*key_event) { + self.app_event_tx.send( + crate::app_event::AppEvent::FullScreenUserVerificationRequest(self.request.clone()), + ); + return true; + } + if self.list_keymap.cancel.is_pressed(*key_event) + || self.approval_keymap.cancel.is_pressed(*key_event) + { + self.cancel(); + return true; + } + if !matches!(self.state, UserVerificationState::Prompt) { + return true; + } + if let Some(idx) = self + .options + .iter() + .position(|option| option.shortcuts.iter().any(|key| key.is_press(*key_event))) + { + self.apply_selection(idx); + return true; + } + false + } +} + +impl BottomPaneView for UserVerificationView { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval) + .with(crate::keymap::KeymapContext::List) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.try_handle_shortcut(&key_event) { + return; + } + self.list.handle_key_event(key_event); + if let Some(idx) = self.list.take_last_selected_index() { + self.apply_selection(idx); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.cancel(); + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + matches!(self.state, UserVerificationState::Complete(_)) + } + + fn completion(&self) -> Option { + match self.state { + UserVerificationState::Complete(completion) => Some(completion), + UserVerificationState::Prompt | UserVerificationState::Waiting => None, + } + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + if matches!(request, ResolvedAppServerRequest::McpElicitation { server_name, request_id } + if server_name == &self.request.server_name && request_id == &self.request.request_id) + { + self.state = UserVerificationState::Complete(ViewCompletion::Accepted); + true + } else { + false + } + } + + fn terminal_title_requires_action(&self) -> bool { + !self.is_complete() + } +} + +impl Renderable for UserVerificationView { + fn desired_height(&self, width: u16) -> u16 { + if matches!(self.state, UserVerificationState::Waiting) { + waiting_view(&self.request, &self.list_keymap).desired_height(width) + } else { + self.list.desired_height(width) + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if matches!(self.state, UserVerificationState::Waiting) { + waiting_view(&self.request, &self.list_keymap).render(area, buf); + } else { + self.list.render(area, buf); + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + matches!(self.state, UserVerificationState::Prompt) + .then(|| self.list.cursor_pos(area)) + .flatten() + } +} + +fn user_verification_options(keymap: &ApprovalKeymap) -> Vec { + vec![ + UserVerificationOption { + label: "Verify and approve".to_string(), + decision: UserVerificationDecision::Verify, + shortcuts: keymap.approve.clone(), + }, + UserVerificationOption { + label: "Cancel this request".to_string(), + decision: UserVerificationDecision::Cancel, + shortcuts: keymap.cancel.clone(), + }, + ] +} + +pub(crate) fn prompt_header(request: &UserVerificationRequest) -> Box { + let mut header = ColumnRenderable::new(); + header.push(Paragraph::new(request.title.clone().bold()).wrap(Wrap { trim: false })); + header.push(Line::from("")); + header.push(request_details(request)); + Box::new(header) +} + +fn request_details(request: &UserVerificationRequest) -> Box { + let mut lines = Vec::new(); + if let Some(thread_label) = &request.thread_label { + lines.push(Line::from(vec![ + "Thread: ".into(), + thread_label.clone().bold(), + ])); + lines.push(Line::from("")); + } + lines.extend([ + Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]), + Line::from(""), + Line::from(request.description.clone()), + ]); + Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })) +} + +fn waiting_view(request: &UserVerificationRequest, keymap: &ListKeymap) -> Box { + let mut view = ColumnRenderable::new(); + view.push(Paragraph::new("Waiting for verification…".bold()).wrap(Wrap { trim: false })); + view.push(Line::from("")); + view.push(request_details(request)); + view.push(Line::from("")); + view.push( + Paragraph::new(accept_cancel_hint_line( + /*accept*/ None, + "", + keymap.primary_hint(ListAction::Cancel), + "to cancel this request", + )) + .wrap(Wrap { trim: false }), + ); + Box::new(view) +} + +#[cfg(test)] +#[path = "user_verification_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/user_verification_tests.rs b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs new file mode 100644 index 0000000000..89c1603787 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/user_verification_tests.rs @@ -0,0 +1,202 @@ +use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +fn render_view_lines(view: &UserVerificationView, width: u16) -> String { + let height = view.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + view.render(Rect::new(0, 0, width, height), &mut buf); + (0..buf.area.height) + .map(|row| { + (0..buf.area.width) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +fn make_view() -> ( + UserVerificationView, + tokio::sync::mpsc::UnboundedReceiver, +) { + make_view_for_request(UserVerificationRequest { + title: "Production deployment needs your approval.".to_string(), + description: "Approve deploying the reviewed release?".to_string(), + thread_label: None, + server_name: "deployments".to_string(), + request_id: RequestId::Integer(12), + }) +} + +fn make_view_for_request( + request: UserVerificationRequest, +) -> ( + UserVerificationView, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (app_tx, _) = unbounded_channel(); + let (decision_tx, decision_rx) = unbounded_channel(); + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let view = UserVerificationView::new( + request, + AppEventSender::new(app_tx), + keymap.approval, + keymap.list, + Box::new(move |decision| { + let _ = decision_tx.send(decision); + }), + ); + (view, decision_rx) +} + +#[test] +fn prompt_snapshot() { + let (view, _app_rx) = make_view(); + assert_snapshot!( + "user_verification_prompt", + render_view_lines(&view, /*width*/ 80) + ); +} + +#[test] +fn title_wraps_at_narrow_widths() { + let (view, _app_rx) = make_view(); + assert_snapshot!( + "user_verification_narrow", + render_view_lines(&view, /*width*/ 20) + ); +} + +#[test] +fn waiting_snapshot_and_duplicate_approval_suppression() { + let (mut view, mut app_rx) = make_view(); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_snapshot!( + "user_verification_waiting", + render_view_lines(&view, /*width*/ 80) + ); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app_rx.try_recv().expect("approval"), + UserVerificationDecision::Verify + ); + assert!(app_rx.try_recv().is_err()); + assert!(!view.is_complete()); +} + +#[test] +fn cancellation_is_available_before_and_during_verification() { + for state in [ + UserVerificationState::Prompt, + UserVerificationState::Waiting, + ] { + for cancel_key in [KeyCode::Esc, KeyCode::Char('c'), KeyCode::Char('x')] { + let (mut view, mut app_rx) = make_view(); + view.state = state; + if cancel_key == KeyCode::Char('x') { + view.approval_keymap.cancel = vec![crate::key_hint::plain(KeyCode::Char('x'))]; + } + view.handle_key_event(KeyEvent::new(cancel_key, KeyModifiers::NONE)); + assert_eq!( + app_rx.try_recv().expect("cancellation"), + UserVerificationDecision::Cancel + ); + assert!(app_rx.try_recv().is_err()); + assert_eq!(view.completion(), Some(ViewCompletion::Cancelled)); + } + } +} + +#[test] +fn resolved_request_dismisses_the_matching_waiting_view() { + let (mut view, _app_rx) = make_view(); + view.state = UserVerificationState::Waiting; + assert!( + !view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "other".to_string(), + request_id: RequestId::Integer(12) + }) + ); + assert!( + view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { + server_name: "deployments".to_string(), + request_id: RequestId::Integer(12) + }) + ); + assert!(view.is_complete()); +} + +#[test] +fn full_screen_request_is_available_before_and_during_verification() { + for state in [ + UserVerificationState::Prompt, + UserVerificationState::Waiting, + ] { + let (mut view, mut decisions) = make_view(); + let (app_tx, mut app_rx) = unbounded_channel(); + view.app_event_tx = AppEventSender::new(app_tx); + view.approval_keymap.open_fullscreen = vec![crate::key_hint::ctrl(KeyCode::Char('v'))]; + view.state = state; + view.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)); + let crate::app_event::AppEvent::FullScreenUserVerificationRequest(request) = + app_rx.try_recv().expect("full-screen request") + else { + panic!("expected full-screen request"); + }; + assert_eq!(request.description, view.request.description); + assert!(decisions.try_recv().is_err()); + assert!(!view.is_complete()); + assert!(view.terminal_title_requires_action()); + } +} + +#[test] +fn long_url_tokens_remain_visible_in_a_narrow_verification_prompt() { + let (view, _) = make_view_for_request(UserVerificationRequest { + title: "Approve https://example.com/production/release/candidate?".to_string(), + description: "Deploy https://example.com/production/critical-release-candidate".to_string(), + thread_label: None, + server_name: "deployments".to_string(), + request_id: RequestId::Integer(12), + }); + assert_snapshot!( + "user_verification_long_urls", + render_view_lines(&view, /*width*/ 30) + ); +} + +#[tokio::test] +async fn full_screen_pager_reaches_the_end_of_a_long_request() -> std::io::Result<()> { + let (view, _) = make_view_for_request(UserVerificationRequest { + title: "Approve release?".to_string(), + description: format!( + "{}Final approval details.", + "Deployment details. ".repeat(30) + ), + thread_label: None, + server_name: "deployments".to_string(), + request_id: RequestId::Integer(12), + }); + let mut pager = crate::pager_overlay::StaticOverlay::with_renderables( + vec![prompt_header(&view.request)], + "USER VERIFICATION".to_string(), + crate::keymap::RuntimeKeymap::defaults().pager, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + let area = Rect::new(0, 0, 40, 12); + let mut buf = Buffer::empty(area); + pager.render(area, &mut buf); + pager.handle_event( + &mut tui, + crate::tui::TuiEvent::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)), + )?; + pager.render(area, &mut buf); + assert_snapshot!("user_verification_full_screen_bottom", format!("{buf:?}")); + Ok(()) +}