Merge pull request #6755 from Hmbown/fix/modal-approval-input

fix(tui): require deliberate approval dialog input
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-29 12:15:08 -07:00
commit 6ce2d2e245
7 files changed
+484 -141

No files matched your search

+23 -18
View File
@@ -7,10 +7,11 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use crossterm::event::{KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use crate::sandbox::SandboxPolicy;
use crate::tui::shell_key_routing::{self, Focus, ShellBindingId};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use crate::tui::widgets::{ElevationWidget, Renderable};
use codewhale_localization::Locale;
@@ -140,9 +141,16 @@ pub struct ElevationView {
impl ElevationView {
pub fn new(request: ElevationRequest, locale: Locale) -> Self {
// Every request offers Abort. A reflexive Enter must never broaden
// access before the person deliberately navigates to another option.
let selected = request
.options
.iter()
.position(|option| matches!(option, ElevationOption::Abort))
.expect("elevation requests offer Abort");
Self {
request,
selected: 0,
selected,
locale,
row_hitboxes: RefCell::new(Vec::new()),
}
@@ -171,7 +179,7 @@ impl ElevationView {
}
/// Get the request for rendering.
#[expect(dead_code)]
#[cfg_attr(not(test), expect(dead_code))]
pub fn request(&self) -> &ElevationRequest {
&self.request
}
@@ -193,28 +201,25 @@ impl ModalView for ElevationView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
// Ordinary text may have been intended for the composer. Even
// selecting on a letter would make that text followed by Enter grant
// access, so only deliberate navigation changes the selected option.
match shell_key_routing::route(Focus::Modal(ModalKind::Elevation), &key) {
Some(ShellBindingId::ElevationUp) => {
self.select_prev();
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
Some(ShellBindingId::ElevationDown) => {
self.select_next();
ViewAction::None
}
KeyCode::Enter => self.emit_decision(self.current_option().clone()),
KeyCode::Char('n') => self.emit_decision(ElevationOption::WithNetwork),
KeyCode::Char('w') => {
// Find the write access option if available
for opt in &self.request.options {
if matches!(opt, ElevationOption::WithWriteAccess(_)) {
return self.emit_decision(opt.clone());
}
}
ViewAction::None
Some(ShellBindingId::ElevationConfirm) => {
self.emit_decision(self.current_option().clone())
}
KeyCode::Char('f') => self.emit_decision(ElevationOption::FullAccess),
KeyCode::Esc | KeyCode::Char('a') => self.emit_decision(ElevationOption::Abort),
Some(ShellBindingId::ElevationAbort) => self.emit_decision(ElevationOption::Abort),
_ => ViewAction::None,
}
}
+249 -93
View File
@@ -1516,6 +1516,59 @@ fn test_approval_view_navigation_keys() {
assert_eq!(view.current_option(), ApprovalOption::Abort);
}
#[test]
fn approval_modified_chords_and_nonpress_keys_cannot_answer() {
for request in [benign_request(), shell_request(), destructive_request()] {
let mut view = ApprovalView::new(request);
// Saving is available and has actually been painted: rejecting these
// chords must come from input admission, not an off-screen preview.
render_lines(&view, 120, 40);
for modifiers in [
KeyModifiers::ALT,
KeyModifiers::CONTROL,
KeyModifiers::SUPER,
] {
for modifiers in [modifiers, modifiers | KeyModifiers::SHIFT] {
for code in "yaYAsSpPnNdDeEgG123jk".chars().map(KeyCode::Char).chain([
KeyCode::Enter,
KeyCode::Up,
KeyCode::Down,
KeyCode::Tab,
KeyCode::Esc,
]) {
let selected = view.selected;
let collapsed = view.collapsed;
assert!(
matches!(
view.handle_key(KeyEvent::new(code, modifiers)),
ViewAction::None
),
"{code:?} with {modifiers:?}"
);
assert_eq!(view.selected, selected);
assert_eq!(view.collapsed, collapsed);
}
}
}
for kind in [
crossterm::event::KeyEventKind::Repeat,
crossterm::event::KeyEventKind::Release,
] {
for code in [
KeyCode::Char('y'),
KeyCode::Char('a'),
KeyCode::Char('s'),
KeyCode::Char('p'),
KeyCode::Enter,
] {
let mut key = create_key_event(code);
key.kind = kind;
assert!(matches!(view.handle_key(key), ViewAction::None));
}
}
}
}
#[test]
fn test_approval_view_view_params() {
// Bare `v` must not open details (TUI-DOG-002).
@@ -1534,6 +1587,12 @@ fn test_approval_view_view_params() {
action,
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
));
if cfg!(target_os = "macos") {
assert!(matches!(
view.handle_key(create_key_event(KeyCode::Char('√'))),
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
));
}
}
#[test]
@@ -2156,111 +2215,181 @@ fn render_takeover_card_fills_most_of_area() {
#[test]
fn test_elevation_view_initial_state() {
let request =
ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
let view = ElevationView::new(request, Locale::En);
assert_eq!(view.selected, 0);
for (network, write) in [(true, false), (false, true), (true, true), (false, false)] {
let request =
ElevationRequest::for_shell("test-id", "cargo build", "blocked", network, write);
let view = ElevationView::new(request, Locale::En);
assert_eq!(
view.request().options[view.selected],
ElevationOption::Abort
);
assert_eq!(
view.approval_request_id(),
None,
"elevation is not an initial approval"
);
}
}
#[test]
fn test_elevation_view_keybindings() {
let request =
ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
let mut view = ElevationView::new(request, Locale::En);
fn elevation_ordinary_typing_then_enter_aborts() {
for (network, write) in [(true, false), (false, true), (true, true), (false, false)] {
let request =
ElevationRequest::for_shell("test-id", "cargo build", "blocked", network, write);
let mut view = ElevationView::new(request, Locale::En);
// Includes every former letter shortcut and navigation letter. The
// user meant to submit this to the composer when the card appeared.
for ch in "fix the parser; make a new file now 123 NJKWFA".chars() {
assert!(matches!(
view.handle_key(create_key_event(KeyCode::Char(ch))),
ViewAction::None
));
assert_eq!(
view.request().options[view.selected],
ElevationOption::Abort
);
}
assert!(matches!(
view.handle_key(create_key_event(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::Abort,
..
})
));
}
}
let action = view.handle_key(create_key_event(KeyCode::Char('n')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::WithNetwork,
..
})
));
let request =
ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
let mut view = ElevationView::new(request, Locale::En);
let action = view.handle_key(create_key_event(KeyCode::Char('w')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::WithWriteAccess(_),
..
})
));
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request, Locale::En);
let action = view.handle_key(create_key_event(KeyCode::Char('f')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::FullAccess,
..
})
));
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request, Locale::En);
let action = view.handle_key(create_key_event(KeyCode::Esc));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::Abort,
..
})
));
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request, Locale::En);
let action = view.handle_key(create_key_event(KeyCode::Char('a')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::Abort,
..
})
));
#[test]
fn elevation_modified_and_nonpress_keys_do_not_select_or_grant() {
let mut view = ElevationView::new(elevation_shell_request(), Locale::En);
for modifiers in [
KeyModifiers::ALT,
KeyModifiers::CONTROL,
KeyModifiers::SUPER,
KeyModifiers::SHIFT,
] {
for code in [
KeyCode::Up,
KeyCode::Down,
KeyCode::Enter,
KeyCode::Esc,
KeyCode::Char('f'),
] {
let selected = view.selected;
assert!(matches!(
view.handle_key(KeyEvent::new(code, modifiers)),
ViewAction::None
));
assert_eq!(view.selected, selected);
}
}
// Repeat/release must not confirm a deliberately selected grant either.
view.handle_key(create_key_event(KeyCode::Up));
assert_eq!(
view.request().options[view.selected],
ElevationOption::FullAccess
);
for kind in [
crossterm::event::KeyEventKind::Repeat,
crossterm::event::KeyEventKind::Release,
] {
for code in [KeyCode::Up, KeyCode::Down, KeyCode::Enter, KeyCode::Esc] {
let mut key = create_key_event(code);
key.kind = kind;
let selected = view.selected;
assert!(matches!(view.handle_key(key), ViewAction::None));
assert_eq!(view.selected, selected);
}
}
}
#[test]
fn test_elevation_view_navigation() {
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
let mut view = ElevationView::new(request, Locale::En);
assert_eq!(view.selected, 0);
let mut view = ElevationView::new(elevation_shell_request(), Locale::En);
assert_eq!(
view.request().options[view.selected],
ElevationOption::Abort
);
view.handle_key(create_key_event(KeyCode::Down));
assert_eq!(view.selected, 1);
assert_eq!(
view.request().options[view.selected],
ElevationOption::WithNetwork
);
view.handle_key(create_key_event(KeyCode::Up));
assert_eq!(view.selected, 0);
view.handle_key(create_key_event(KeyCode::Char('j')));
assert_eq!(view.selected, 1);
view.handle_key(create_key_event(KeyCode::Char('k')));
assert_eq!(view.selected, 0);
assert_eq!(
view.request().options[view.selected],
ElevationOption::Abort
);
view.handle_key(create_key_event(KeyCode::Up));
assert_eq!(
view.request().options[view.selected],
ElevationOption::FullAccess
);
}
#[test]
fn test_elevation_view_enter_uses_selected_option() {
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, true);
for expected in request.options.clone() {
let mut view = ElevationView::new(request.clone(), Locale::En);
while view.request().options[view.selected] != expected {
assert!(matches!(
view.handle_key(create_key_event(KeyCode::Down)),
ViewAction::None
));
}
let ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option, tool_id, ..
}) = view.handle_key(create_key_event(KeyCode::Enter))
else {
panic!("Enter should commit the selected offered option");
};
assert_eq!(option, expected);
assert_eq!(tool_id, "test-id");
}
let mut view = ElevationView::new(request, Locale::En);
view.handle_key(create_key_event(KeyCode::Down));
assert_eq!(view.selected, 1);
let action = view.handle_key(create_key_event(KeyCode::Enter));
assert!(matches!(
action,
view.handle_key(create_key_event(KeyCode::Esc)),
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::FullAccess,
option: ElevationOption::Abort,
..
})
));
}
#[test]
fn elevation_mouse_commits_the_visible_offered_row() {
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, true);
for (w, h) in [(40, 12), (60, 16), (70, 22), (80, 24), (100, 32), (140, 40)] {
for expected in request.options.clone() {
let mut view = ElevationView::new(request.clone(), Locale::En);
let lines = render_elevation_lines(&view, w, h);
let (row, column) = lines
.iter()
.enumerate()
.find_map(|(row, line)| {
// The full-access description may wrap at 40 columns.
line.find(expected.label().split(" (").next().expect("label"))
.map(|byte| (row, line[..byte].chars().count()))
})
.expect("offered option is visible");
let ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option, tool_id, ..
}) = view.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: column as u16,
row: row as u16,
modifiers: KeyModifiers::NONE,
})
else {
panic!("{w}x{h}: clicking the visible label must decide its row");
};
assert_eq!(option, expected, "{w}x{h}");
assert_eq!(tool_id, "test-id");
}
}
}
fn render_elevation_lines(view: &ElevationView, w: u16, h: u16) -> Vec<String> {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -2307,19 +2436,46 @@ fn elevation_always_paints_every_option_including_the_safe_exit() {
// `Abort` — the only choice that grants nothing — was unreachable by sight
// at every terminal size. Options are reserved now; the denial detail is
// what shortens.
let view = ElevationView::new(elevation_shell_request(), Locale::En);
for (w, h) in [(70, 22), (80, 24), (100, 32), (140, 40), (60, 16)] {
let joined = compact_elevation_text(&render_elevation_lines(&view, w, h));
for option in ["Abort", "Fullaccess", "Allowoutboundnetwork"] {
let mut original = elevation_shell_request();
original
.options
.insert(1, ElevationOption::WithWriteAccess(vec![]));
let mut long = original.clone();
long.command = Some("cargo build --package codewhale-tui ".repeat(40));
long.denial_reason = "Network blocked; retry requires an explicit choice. ".repeat(80);
for request in [original, long] {
let view = ElevationView::new(request, Locale::En);
for (w, h) in [(40, 12), (60, 16), (70, 22), (80, 24), (100, 32), (140, 40)] {
let joined = compact_elevation_text(&render_elevation_lines(&view, w, h));
for option in [
"Abort",
"Fullaccess",
"Allowoutboundnetwork",
"Allowextrawriteaccess",
] {
assert!(
joined.contains(option),
"{w}x{h}: option '{option}' is not on screen:\n{joined}"
);
}
assert!(
joined.contains(option),
"{w}x{h}: option '{option}' is not on screen:\n{joined}"
joined.contains("SandboxDenied") || joined.contains("SandboxElevationRequired"),
"{w}x{h}: the card lost its title:\n{joined}"
);
for key in ["↑/↓", "Enter", "Esc"] {
assert!(
joined.contains(key),
"{w}x{h}: missing keyboard access {key}:\n{joined}"
);
}
assert!(
joined.contains(&format!("{}Abort", crate::tui::glyphs::SELECTION)),
"{w}x{h}: Abort needs a visible marker, not only a color change:\n{joined}"
);
for old_hint in ["[n]", "[w]", "[f]", "[a]"] {
assert!(!joined.contains(old_hint), "obsolete shortcut {old_hint}");
}
}
assert!(
joined.contains("SandboxDenied"),
"{w}x{h}: the card lost its title:\n{joined}"
);
}
}
+14 -5
View File
@@ -8,7 +8,9 @@ use std::cell::{Cell, RefCell};
use std::time::{Duration, Instant};
use codewhale_config::ToolAskRule;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Rect;
use crate::config::ApprovalDefaultSelection;
@@ -339,6 +341,17 @@ impl ModalView for ApprovalView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
// Details is the intentional modified-key action; it cannot grant
// authority. Editing/mode chords must never answer this card.
if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) {
return self.emit_params_pager();
}
if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
return ViewAction::None;
}
match key.code {
KeyCode::Tab => {
self.collapsed = !self.collapsed;
@@ -395,10 +408,6 @@ impl ModalView for ApprovalView {
self.commit_option(ApprovalOption::Deny)
}
}
// Details is Alt+V / Option+V only; bare `v` is never a shortcut.
_ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => {
self.emit_params_pager()
}
// A child's card hides on Esc: the request stays pending (footer
// row, `/agents`) and the parent's turn is never cancelled.
KeyCode::Esc if self.request.owner.is_some() => ViewAction::Close,
+81
View File
@@ -52,6 +52,8 @@ pub enum Focus {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusScope {
PetHabitat,
/// Only the sandbox-elevation decision card.
Elevation,
/// Only the model-bound redaction consent gate.
RedactionGate,
/// A live session: the composer, or a rail/workflow panel that has taken
@@ -72,6 +74,7 @@ impl FocusScope {
pub fn admits(self, focus: Focus) -> bool {
match self {
Self::PetHabitat => focus == Focus::Modal(ModalKind::PetHabitat),
Self::Elevation => focus == Focus::Modal(ModalKind::Elevation),
Self::RedactionGate => focus == Focus::RedactionGate,
Self::SessionShell => matches!(focus, Focus::Composer | Focus::Panel),
Self::AnyShell => matches!(focus, Focus::Composer | Focus::Panel | Focus::Launch),
@@ -87,6 +90,10 @@ impl FocusScope {
/// Stable binding ids shared by handlers, footer hints, and help catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellBindingId {
ElevationUp,
ElevationDown,
ElevationConfirm,
ElevationAbort,
PetResultUp,
PetResultDown,
PetResultPageUp,
@@ -133,6 +140,12 @@ impl ShellBinding {
#[must_use]
pub fn matches(&self, key: &KeyEvent) -> bool {
match self.id {
ShellBindingId::ElevationUp => key.code == KeyCode::Up && key.modifiers.is_empty(),
ShellBindingId::ElevationDown => key.code == KeyCode::Down && key.modifiers.is_empty(),
ShellBindingId::ElevationConfirm => {
key.code == KeyCode::Enter && key.modifiers.is_empty()
}
ShellBindingId::ElevationAbort => key.code == KeyCode::Esc && key.modifiers.is_empty(),
ShellBindingId::PetResultUp => key.code == KeyCode::Up && key.modifiers.is_empty(),
ShellBindingId::PetResultDown => key.code == KeyCode::Down && key.modifiers.is_empty(),
ShellBindingId::PetResultPageUp => {
@@ -185,6 +198,30 @@ pub fn route(focus: Focus, key: &KeyEvent) -> Option<ShellBindingId> {
/// Canonical shell bindings. Handlers and chrome read from here.
pub const SHELL_BINDINGS: &[ShellBinding] = &[
ShellBinding {
id: ShellBindingId::ElevationUp,
catalog_chord: "Up",
footer_chord: "↑",
focus: FocusScope::Elevation,
},
ShellBinding {
id: ShellBindingId::ElevationDown,
catalog_chord: "Down",
footer_chord: "↓",
focus: FocusScope::Elevation,
},
ShellBinding {
id: ShellBindingId::ElevationConfirm,
catalog_chord: "Enter",
footer_chord: "Enter",
focus: FocusScope::Elevation,
},
ShellBinding {
id: ShellBindingId::ElevationAbort,
catalog_chord: "Esc",
footer_chord: "Esc",
focus: FocusScope::Elevation,
},
ShellBinding {
id: ShellBindingId::PetResultUp,
catalog_chord: "Up",
@@ -773,6 +810,50 @@ mod tests {
assert!(help.matches(&KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL)));
}
#[test]
fn elevation_bindings_are_nontext_and_stay_in_the_elevation_card() {
for (code, id) in [
(KeyCode::Up, ShellBindingId::ElevationUp),
(KeyCode::Down, ShellBindingId::ElevationDown),
(KeyCode::Enter, ShellBindingId::ElevationConfirm),
(KeyCode::Esc, ShellBindingId::ElevationAbort),
] {
let key = KeyEvent::new(code, KeyModifiers::NONE);
assert_eq!(route(Focus::Modal(ModalKind::Elevation), &key), Some(id));
assert!(!binding(id).footer_chord.is_empty());
for focus in [
Focus::Composer,
Focus::Launch,
Focus::Modal(ModalKind::Approval),
] {
assert_ne!(route(focus, &key), Some(id));
}
for modifiers in [
KeyModifiers::ALT,
KeyModifiers::CONTROL,
KeyModifiers::SUPER,
KeyModifiers::SHIFT,
] {
assert_ne!(
route(
Focus::Modal(ModalKind::Elevation),
&KeyEvent::new(code, modifiers)
),
Some(id)
);
}
}
for letter in "nwfajkN W F A J K123".chars() {
assert_eq!(
route(
Focus::Modal(ModalKind::Elevation),
&KeyEvent::new(KeyCode::Char(letter), KeyModifiers::NONE)
),
None
);
}
}
#[test]
fn focus_scopes_nest_from_the_session_outwards() {
let ladder = [
+52
View File
@@ -880,6 +880,10 @@ fn bracketed_paste_returns_dock_focus_to_the_visible_composer() {
/// One representative terminal encoding per shell binding.
fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
match id {
ShellBindingId::ElevationUp => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
ShellBindingId::ElevationDown => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::ElevationConfirm => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
ShellBindingId::ElevationAbort => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::PetResultUp => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
ShellBindingId::PetResultDown => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::PetResultPageUp => KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
@@ -26695,6 +26699,54 @@ fn typeahead_before_card_does_not_answer() {
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Approval));
}
#[test]
fn stale_keys_cannot_answer_a_raised_or_revealed_elevation() {
use crate::tui::approval::ElevationOption;
let mut app = ask_posture_app();
let typed_before = Instant::now() - Duration::from_secs(1);
app.view_stack.push(ElevationView::new(
ElevationRequest::for_shell("elevation-id", "cargo test", "blocked", true, false),
codewhale_localization::Locale::En,
));
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
assert!(route_key_to_view_stack(&mut app, up, typed_before).is_none());
assert_eq!(app.view_stack.top_approval_id(), None);
// Deliberately select Full Access, then cover it with another decision.
assert!(route_key_to_view_stack(&mut app, up, Instant::now()).is_some());
push_approval_request_view(
&mut app,
"approval-id",
"exec_shell",
"Run a command",
&serde_json::json!({"command": "cargo test"}),
"k",
"g",
None,
crate::config::ApprovalDefaultSelection::AllowOnce,
None,
);
let queued_enter = Instant::now();
std::thread::sleep(Duration::from_millis(2));
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let events = route_key_to_view_stack(&mut app, enter, queued_enter).expect("visible approval");
assert!(
matches!(events.as_slice(), [ViewEvent::ApprovalDecision { tool_id, .. }] if tool_id == "approval-id")
);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Elevation));
assert!(
route_key_to_view_stack(&mut app, enter, queued_enter).is_none(),
"Enter queued for the previous card must not elevate the newly revealed one"
);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Elevation));
let events =
route_key_to_view_stack(&mut app, enter, Instant::now()).expect("fresh confirmation");
assert!(matches!(events.as_slice(), [ViewEvent::ElevationDecision {
tool_id, option: ElevationOption::FullAccess, ..
}] if tool_id == "elevation-id"));
assert!(app.view_stack.is_empty());
}
#[test]
fn second_quick_y_does_not_answer_the_card_it_reveals() {
let mut app = ask_posture_app();
+10 -8
View File
@@ -1224,7 +1224,7 @@ pub struct ViewStack {
/// When the view now on top became the top view — pushed, or revealed
/// by closing or removing the views above it. A key observed before
/// this instant was typed at something else and must never answer an
/// approval card that only then became visible (approvals M2).
/// approval or elevation card that only then became visible (approvals M2).
top_since: Option<std::time::Instant>,
}
@@ -1315,14 +1315,16 @@ impl ViewStack {
}
/// Whether a key observed at `observed_at` predates the moment the
/// approval card on top became visible — raised, or revealed by closing
/// the card above it — i.e. it was typed ahead and must not answer that
/// card. Two quick `y` presses answer one card, never the one beneath.
/// approval or elevation card on top became visible — raised, or revealed
/// by closing the card above it — i.e. it was typed ahead and must not
/// answer that card. Two quick answers cannot confirm the card beneath.
pub fn key_predates_top_approval(&self, observed_at: std::time::Instant) -> bool {
self.top_approval_id().is_some()
&& self
.top_since
.is_some_and(|top_since| observed_at < top_since)
matches!(
self.top_kind(),
Some(ModalKind::Approval | ModalKind::Elevation)
) && self
.top_since
.is_some_and(|top_since| observed_at < top_since)
}
pub fn contains_kind(&self, kind: ModalKind) -> bool {
+55 -17
View File
@@ -3495,24 +3495,20 @@ impl Renderable for ElevationWidget<'_> {
Style::default()
};
let (key, label_id, desc_id) = match option {
let (label_id, desc_id) = match option {
ElevationOption::WithNetwork => (
"n",
MessageId::ElevationOptionNetwork,
MessageId::ElevationOptionNetworkDesc,
),
ElevationOption::WithWriteAccess(_) => (
"w",
MessageId::ElevationOptionWrite,
MessageId::ElevationOptionWriteDesc,
),
ElevationOption::FullAccess => (
"f",
MessageId::ElevationOptionFullAccess,
MessageId::ElevationOptionFullAccessDesc,
),
ElevationOption::Abort => (
"a",
MessageId::ElevationOptionAbort,
MessageId::ElevationOptionAbortDesc,
),
@@ -3527,8 +3523,8 @@ impl Renderable for ElevationWidget<'_> {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{key}] "),
Style::default().fg(palette::STATUS_SUCCESS),
format!("{} ", crate::tui::glyphs::selection_marker(is_selected)),
style,
),
Span::styled(tr(self.locale, label_id), style.fg(label_color)),
]));
@@ -3552,12 +3548,39 @@ impl Renderable for ElevationWidget<'_> {
let inner_width = popup_width.saturating_sub(CHROME);
let max_inner_height = area.height.saturating_sub(2).saturating_sub(CHROME);
// The same binding table routes these keys. Reserve its hint alongside
// the choices so truncating the denial never hides keyboard access.
use crate::tui::shell_key_routing::{ShellBindingId, binding};
let controls = Line::from(Span::styled(
format!(
" {}/{} · {} · {}",
binding(ShellBindingId::ElevationUp).footer_chord,
binding(ShellBindingId::ElevationDown).footer_chord,
binding(ShellBindingId::ElevationConfirm).footer_chord,
binding(ShellBindingId::ElevationAbort).footer_chord,
),
Style::default().fg(palette::TEXT_MUTED),
));
let controls_rows = measure_wrapped_rows(std::slice::from_ref(&controls), inner_width);
// Elevation has no details pager. Do not reuse the initial-approval
// hint that advertises a details shortcut this card cannot handle.
let truncation_hint = Line::from(Span::styled(
" …",
Style::default().fg(palette::TEXT_MUTED),
));
let truncation_rows =
measure_wrapped_rows(std::slice::from_ref(&truncation_hint), inner_width);
let mut option_lines = lines.split_off(option_start);
// Each option is a label row followed by a description row. On a terminal
// too small for both, the description is chrome and the choice is
// content, so the descriptions go first and every option keeps its row.
let mut rows_per_option = 2usize;
if measure_wrapped_rows(&option_lines, inner_width) > max_inner_height {
if measure_wrapped_rows(&option_lines, inner_width)
.saturating_add(controls_rows)
.saturating_add(2 + truncation_rows)
> max_inner_height
{
option_lines = option_lines
.into_iter()
.enumerate()
@@ -3565,21 +3588,35 @@ impl Renderable for ElevationWidget<'_> {
.collect();
rows_per_option = 1;
}
let option_rows = measure_wrapped_rows(&option_lines, inner_width);
// Trim the denial detail down to the title rather than the option list.
let option_rows =
measure_wrapped_rows(&option_lines, inner_width).saturating_add(controls_rows);
// Empty separators are chrome. Remove them before truncating useful
// detail; on the smallest frame even the preamble can go, since the
// border still names the card and the choices must remain reachable.
if measure_wrapped_rows(&lines, inner_width)
.saturating_add(option_rows)
.saturating_add(truncation_rows)
> max_inner_height
{
lines.retain(|line| line.width() != 0);
}
let mut truncated = false;
while lines.len() > 2
&& measure_wrapped_rows(&lines, inner_width).saturating_add(option_rows)
while !lines.is_empty()
&& measure_wrapped_rows(&lines, inner_width)
.saturating_add(option_rows)
.saturating_add(truncation_rows)
> max_inner_height
{
lines.pop();
truncated = true;
}
if truncated {
lines.push(Line::from(Span::styled(
approval_truncation_hint(self.locale),
Style::default().fg(palette::TEXT_MUTED),
)));
if truncated
&& measure_wrapped_rows(&lines, inner_width)
.saturating_add(option_rows)
.saturating_add(truncation_rows)
<= max_inner_height
{
lines.push(truncation_hint);
}
// Row offsets are measured after wrapping, not counted in source lines:
@@ -3596,6 +3633,7 @@ impl Renderable for ElevationWidget<'_> {
offsets
};
lines.extend(option_lines);
lines.push(controls);
let popup_height = measure_wrapped_rows(&lines, inner_width)
.saturating_add(CHROME)