mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
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
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+27
@@ -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,
|
||||
]
|
||||
}
|
||||
+21
@@ -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
|
||||
+25
@@ -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
|
||||
+15
@@ -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
|
||||
+11
@@ -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
|
||||
@@ -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<String>,
|
||||
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<KeyBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum UserVerificationState {
|
||||
Prompt,
|
||||
Waiting,
|
||||
Complete(ViewCompletion),
|
||||
}
|
||||
|
||||
pub(crate) struct UserVerificationView {
|
||||
request: UserVerificationRequest,
|
||||
on_decision: Box<dyn Fn(UserVerificationDecision)>,
|
||||
list: ListSelectionView,
|
||||
options: Vec<UserVerificationOption>,
|
||||
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<dyn Fn(UserVerificationDecision)>,
|
||||
) -> 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<ViewCompletion> {
|
||||
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<UserVerificationOption> {
|
||||
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<dyn Renderable> {
|
||||
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<dyn Renderable> {
|
||||
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<dyn Renderable> {
|
||||
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;
|
||||
@@ -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::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn make_view() -> (
|
||||
UserVerificationView,
|
||||
tokio::sync::mpsc::UnboundedReceiver<UserVerificationDecision>,
|
||||
) {
|
||||
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<UserVerificationDecision>,
|
||||
) {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user