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:
riley-oai
2026-09-08 04:47:42 +00:00
committed by copyberry
parent 6b6fdc3572
commit 54e04f25db
10 changed files with 618 additions and 0 deletions
+8
View File
@@ -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();
+5
View File
@@ -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,
+2
View File
@@ -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;
@@ -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,
]
}
@@ -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
@@ -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
@@ -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
@@ -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(())
}