mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
Merge pull request #6755 from Hmbown/fix/modal-approval-input
fix(tui): require deliberate approval dialog input
This commit is contained in:
7 files changed
+484
-141
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user