| 1 | //! Approval option selection and modal state. |
| 2 | //! |
| 3 | //! This module owns approval-card interaction and event emission. Risk policy, |
| 4 | //! persistent rules, preview formatting, and sandbox elevation remain separate |
| 5 | //! authority boundaries. |
| 6 | |
| 7 | use std::cell::RefCell; |
| 8 | use std::time::{Duration, Instant}; |
| 9 | |
| 10 | use codewhale_config::ToolAskRule; |
| 11 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; |
| 12 | use ratatui::layout::Rect; |
| 13 | |
| 14 | use crate::config::ApprovalDefaultSelection; |
| 15 | use crate::tools::canonical_action::canonical_action_alias; |
| 16 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 17 | use crate::tui::widgets::{ApprovalWidget, Renderable}; |
| 18 | use codewhale_localization::{Locale, MessageId, tr}; |
| 19 | |
| 20 | #[cfg(test)] |
| 21 | use super::RiskLevel; |
| 22 | use super::previews::exact_edit_file_preview_lines; |
| 23 | use super::{ApprovalRequest, ReviewDecision}; |
| 24 | |
| 25 | /// Indices into the option list shared by both variants. |
| 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 27 | pub enum ApprovalOption { |
| 28 | ApproveOnce, |
| 29 | ApproveAlways, |
| 30 | AllowExactRepo, |
| 31 | Deny, |
| 32 | Abort, |
| 33 | } |
| 34 | |
| 35 | impl ApprovalOption { |
| 36 | const ORDER: [ApprovalOption; 4] = [ |
| 37 | ApprovalOption::ApproveOnce, |
| 38 | ApprovalOption::ApproveAlways, |
| 39 | ApprovalOption::Deny, |
| 40 | ApprovalOption::Abort, |
| 41 | ]; |
| 42 | const ORDER_WITH_PERSISTENT_ALLOW: [ApprovalOption; 5] = [ |
| 43 | ApprovalOption::ApproveOnce, |
| 44 | ApprovalOption::ApproveAlways, |
| 45 | ApprovalOption::AllowExactRepo, |
| 46 | ApprovalOption::Deny, |
| 47 | ApprovalOption::Abort, |
| 48 | ]; |
| 49 | |
| 50 | /// Workflow elevated-plan card (#4126): Approve / Edit plan / Cancel. |
| 51 | const WORKFLOW_ORDER: [ApprovalOption; 3] = [ |
| 52 | ApprovalOption::ApproveOnce, |
| 53 | ApprovalOption::Deny, |
| 54 | ApprovalOption::Abort, |
| 55 | ]; |
| 56 | |
| 57 | fn order_for(request: &ApprovalRequest) -> &'static [ApprovalOption] { |
| 58 | if request.tool_name == "workflow" { |
| 59 | &Self::WORKFLOW_ORDER |
| 60 | } else if request.can_save_allow_rule() { |
| 61 | &Self::ORDER_WITH_PERSISTENT_ALLOW |
| 62 | } else { |
| 63 | &Self::ORDER |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | fn from_index_for(request: &ApprovalRequest, idx: usize) -> ApprovalOption { |
| 68 | Self::order_for(request) |
| 69 | .get(idx) |
| 70 | .copied() |
| 71 | .unwrap_or(Self::Abort) |
| 72 | } |
| 73 | |
| 74 | fn index_for(self, request: &ApprovalRequest) -> usize { |
| 75 | Self::order_for(request) |
| 76 | .iter() |
| 77 | .position(|o| *o == self) |
| 78 | .unwrap_or(Self::order_for(request).len().saturating_sub(1)) |
| 79 | } |
| 80 | |
| 81 | fn decision(self) -> ReviewDecision { |
| 82 | match self { |
| 83 | ApprovalOption::ApproveOnce => ReviewDecision::Approved, |
| 84 | ApprovalOption::ApproveAlways => ReviewDecision::ApprovedForSession, |
| 85 | ApprovalOption::AllowExactRepo => ReviewDecision::Approved, |
| 86 | // Workflow maps Deny → "Edit plan" (model revises plan). |
| 87 | ApprovalOption::Deny => ReviewDecision::Denied, |
| 88 | ApprovalOption::Abort => ReviewDecision::Abort, |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// Approval overlay state managed by the modal view stack |
| 94 | #[derive(Debug, Clone)] |
| 95 | pub struct ApprovalView { |
| 96 | request: ApprovalRequest, |
| 97 | pub(super) selected: usize, |
| 98 | pub(super) row_hitboxes: RefCell<Vec<Rect>>, |
| 99 | locale: Locale, |
| 100 | pub(super) timeout: Option<Duration>, |
| 101 | pub(super) requested_at: Instant, |
| 102 | /// Whether the approval card is collapsed to a single-line banner. |
| 103 | pub(crate) collapsed: bool, |
| 104 | } |
| 105 | |
| 106 | impl ApprovalView { |
| 107 | #[cfg(test)] |
| 108 | pub fn new(request: ApprovalRequest) -> Self { |
| 109 | Self::new_for_locale(request, Locale::En) |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | pub fn new_for_locale(request: ApprovalRequest, locale: Locale) -> Self { |
| 114 | Self::new_with_default_selection(request, locale, ApprovalDefaultSelection::default()) |
| 115 | } |
| 116 | |
| 117 | /// `default_selection` is `[approval] default_selection` (#5293). Deny |
| 118 | /// stays the default so a fresh card never turns a reflexive Enter into |
| 119 | /// authorization; `allow_once` is a user opting out of that guard. |
| 120 | pub fn new_with_default_selection( |
| 121 | request: ApprovalRequest, |
| 122 | locale: Locale, |
| 123 | default_selection: ApprovalDefaultSelection, |
| 124 | ) -> Self { |
| 125 | // Resolve the semantic option because its numeric index differs for |
| 126 | // persistent-allow and workflow approval cards. |
| 127 | let selected = match default_selection { |
| 128 | ApprovalDefaultSelection::Deny => ApprovalOption::Deny, |
| 129 | ApprovalDefaultSelection::AllowOnce => ApprovalOption::ApproveOnce, |
| 130 | } |
| 131 | .index_for(&request); |
| 132 | Self { |
| 133 | request, |
| 134 | selected, |
| 135 | row_hitboxes: RefCell::new(Vec::new()), |
| 136 | locale, |
| 137 | timeout: None, |
| 138 | requested_at: Instant::now(), |
| 139 | collapsed: false, |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /// Bound how long this card may wait (#6101). `Some(timeout)` resolves |
| 144 | /// the card to **deny** once the duration elapses (fail-closed); `None` |
| 145 | /// waits indefinitely. A zero duration is treated as `None` so the |
| 146 | /// config convention (`0` = wait forever) holds at this layer too. |
| 147 | #[must_use] |
| 148 | pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self { |
| 149 | self.timeout = timeout.filter(|timeout| !timeout.is_zero()); |
| 150 | self |
| 151 | } |
| 152 | |
| 153 | pub(super) fn select_prev(&mut self) { |
| 154 | let len = ApprovalOption::order_for(&self.request).len(); |
| 155 | self.selected = crate::tui::list_nav::wrap_index(self.selected, len, -1); |
| 156 | } |
| 157 | |
| 158 | pub(super) fn select_next(&mut self) { |
| 159 | let len = ApprovalOption::order_for(&self.request).len(); |
| 160 | self.selected = crate::tui::list_nav::wrap_index(self.selected, len, 1); |
| 161 | } |
| 162 | |
| 163 | pub(super) fn current_option(&self) -> ApprovalOption { |
| 164 | ApprovalOption::from_index_for(&self.request, self.selected) |
| 165 | } |
| 166 | |
| 167 | /// Whether this approval is the elevated Workflow plan card (#4126). |
| 168 | #[must_use] |
| 169 | pub fn is_workflow_plan_approval(&self) -> bool { |
| 170 | self.request.tool_name == "workflow" |
| 171 | } |
| 172 | |
| 173 | /// Test-only accessor for the selected option's decision. |
| 174 | #[cfg(test)] |
| 175 | pub(super) fn current_decision(&self) -> ReviewDecision { |
| 176 | self.current_option().decision() |
| 177 | } |
| 178 | |
| 179 | /// Selected option for the renderer (used by the widget tests too). |
| 180 | pub fn selected(&self) -> usize { |
| 181 | self.selected |
| 182 | } |
| 183 | |
| 184 | pub(crate) fn set_mouse_hitboxes(&self, hitboxes: Vec<Rect>) { |
| 185 | *self.row_hitboxes.borrow_mut() = hitboxes; |
| 186 | } |
| 187 | |
| 188 | /// Risk level for the renderer's accent picking. |
| 189 | #[cfg(test)] |
| 190 | pub fn risk(&self) -> RiskLevel { |
| 191 | self.request.risk |
| 192 | } |
| 193 | |
| 194 | pub(crate) fn locale(&self) -> Locale { |
| 195 | self.locale |
| 196 | } |
| 197 | |
| 198 | /// Commit the given option and close the approval modal. |
| 199 | fn commit_option(&mut self, option: ApprovalOption) -> ViewAction { |
| 200 | self.selected = option.index_for(&self.request); |
| 201 | if option == ApprovalOption::AllowExactRepo && self.request.can_save_allow_rule() { |
| 202 | self.emit_decision_with_rules( |
| 203 | option.decision(), |
| 204 | false, |
| 205 | self.request.persistent_allow_rules.clone(), |
| 206 | ) |
| 207 | } else { |
| 208 | self.emit_decision(option.decision(), false) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | fn emit_decision(&self, decision: ReviewDecision, timed_out: bool) -> ViewAction { |
| 213 | self.emit_decision_with_rules(decision, timed_out, Vec::new()) |
| 214 | } |
| 215 | |
| 216 | fn emit_decision_with_rules( |
| 217 | &self, |
| 218 | decision: ReviewDecision, |
| 219 | timed_out: bool, |
| 220 | persistent_rules: Vec<ToolAskRule>, |
| 221 | ) -> ViewAction { |
| 222 | ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { |
| 223 | tool_id: self.request.id.clone(), |
| 224 | tool_name: self.request.tool_name.clone(), |
| 225 | decision, |
| 226 | timed_out, |
| 227 | approval_key: self.request.approval_key.clone(), |
| 228 | approval_grouping_key: self.request.approval_grouping_key.clone(), |
| 229 | persistent_rules, |
| 230 | }) |
| 231 | } |
| 232 | |
| 233 | fn emit_params_pager(&self) -> ViewAction { |
| 234 | // The compact prompt keeps the about/impact dossier out of the |
| 235 | // default band; the pager is where that context now lives. |
| 236 | let locale = self.locale(); |
| 237 | let about_label = tr(locale, MessageId::ApprovalLabelAbout); |
| 238 | let impact_label = tr(locale, MessageId::ApprovalLabelImpact); |
| 239 | let mut content = String::new(); |
| 240 | content.push_str(&about_label); |
| 241 | content.push_str(&self.request.description_for_locale(locale)); |
| 242 | content.push('\n'); |
| 243 | for impact in self.request.impacts_for_locale(locale) { |
| 244 | content.push_str(&impact_label); |
| 245 | content.push_str(&impact); |
| 246 | content.push('\n'); |
| 247 | } |
| 248 | content.push('\n'); |
| 249 | if canonical_action_alias(&self.request.tool_name, &self.request.params) == "edit_file" |
| 250 | && let Some(preview_lines) = exact_edit_file_preview_lines(&self.request.params, locale) |
| 251 | { |
| 252 | content.push_str(&tr(locale, MessageId::ApprovalLabelPreview)); |
| 253 | content.push_str(":\n"); |
| 254 | for line in preview_lines { |
| 255 | content.push_str(&line); |
| 256 | content.push('\n'); |
| 257 | } |
| 258 | content.push('\n'); |
| 259 | } |
| 260 | content.push_str( |
| 261 | &serde_json::to_string_pretty(&self.request.params) |
| 262 | .unwrap_or_else(|_| self.request.params.to_string()), |
| 263 | ); |
| 264 | ViewAction::Emit(ViewEvent::OpenTextPager { |
| 265 | title: format!("Tool Params: {}", self.request.tool_name), |
| 266 | content, |
| 267 | }) |
| 268 | } |
| 269 | |
| 270 | fn is_timed_out(&self) -> bool { |
| 271 | match self.timeout { |
| 272 | Some(timeout) => self.requested_at.elapsed() >= timeout, |
| 273 | None => false, |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | impl ModalView for ApprovalView { |
| 279 | fn kind(&self) -> ModalKind { |
| 280 | ModalKind::Approval |
| 281 | } |
| 282 | |
| 283 | fn approval_request_id(&self) -> Option<&str> { |
| 284 | Some(&self.request.id) |
| 285 | } |
| 286 | |
| 287 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 288 | self |
| 289 | } |
| 290 | |
| 291 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 292 | match key.code { |
| 293 | KeyCode::Tab => { |
| 294 | self.collapsed = !self.collapsed; |
| 295 | ViewAction::None |
| 296 | } |
| 297 | KeyCode::Up | KeyCode::Char('k') => { |
| 298 | self.select_prev(); |
| 299 | ViewAction::None |
| 300 | } |
| 301 | KeyCode::Down | KeyCode::Char('j') => { |
| 302 | self.select_next(); |
| 303 | ViewAction::None |
| 304 | } |
| 305 | KeyCode::Enter => self.commit_option(self.current_option()), |
| 306 | // Direct shortcuts; '1' / '2' map to the first two options |
| 307 | // so a numeric pad still works for approve flows. |
| 308 | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') => { |
| 309 | self.commit_option(ApprovalOption::ApproveOnce) |
| 310 | } |
| 311 | KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Char('2') |
| 312 | if !self.is_workflow_plan_approval() => |
| 313 | { |
| 314 | self.commit_option(ApprovalOption::ApproveAlways) |
| 315 | } |
| 316 | KeyCode::Char('p') | KeyCode::Char('P') if self.request.can_save_allow_rule() => { |
| 317 | self.commit_option(ApprovalOption::AllowExactRepo) |
| 318 | } |
| 319 | // Workflow plan card (#4126): [2/e] Edit plan, [3/n/d] Cancel. |
| 320 | KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Char('2') |
| 321 | if self.is_workflow_plan_approval() => |
| 322 | { |
| 323 | self.commit_option(ApprovalOption::Deny) |
| 324 | } |
| 325 | KeyCode::Char('s') | KeyCode::Char('S') if self.request.can_save_ask_rule() => self |
| 326 | .emit_decision_with_rules( |
| 327 | ReviewDecision::Approved, |
| 328 | false, |
| 329 | self.request.persistent_ask_rules.clone(), |
| 330 | ), |
| 331 | KeyCode::Char('n') |
| 332 | | KeyCode::Char('N') |
| 333 | | KeyCode::Char('d') |
| 334 | | KeyCode::Char('D') |
| 335 | | KeyCode::Char('3') => { |
| 336 | if self.is_workflow_plan_approval() { |
| 337 | // Cancel (abort turn) rather than session-deny. |
| 338 | self.commit_option(ApprovalOption::Abort) |
| 339 | } else { |
| 340 | self.commit_option(ApprovalOption::Deny) |
| 341 | } |
| 342 | } |
| 343 | // Details is Alt+V / Option+V only; bare `v` is never a shortcut. |
| 344 | _ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => { |
| 345 | self.emit_params_pager() |
| 346 | } |
| 347 | KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false), |
| 348 | _ => ViewAction::None, |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 353 | match mouse.kind { |
| 354 | MouseEventKind::ScrollUp => { |
| 355 | self.select_prev(); |
| 356 | ViewAction::None |
| 357 | } |
| 358 | MouseEventKind::ScrollDown => { |
| 359 | self.select_next(); |
| 360 | ViewAction::None |
| 361 | } |
| 362 | MouseEventKind::Down(MouseButton::Left) => { |
| 363 | let clicked = self.row_hitboxes.borrow().iter().position(|rect| { |
| 364 | rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) |
| 365 | }); |
| 366 | if let Some(index) = clicked { |
| 367 | return self |
| 368 | .commit_option(ApprovalOption::from_index_for(&self.request, index)); |
| 369 | } |
| 370 | ViewAction::None |
| 371 | } |
| 372 | _ => ViewAction::None, |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) { |
| 377 | let approval_widget = ApprovalWidget::new(&self.request, self); |
| 378 | approval_widget.render(area, buf); |
| 379 | } |
| 380 | |
| 381 | fn occupied_region(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect { |
| 382 | // The approval is an inline, bottom-anchored prompt: it only occupies |
| 383 | // a band at the bottom of the frame so the backdrop dims that band and |
| 384 | // the transcript above stays visible. Must match what `render` paints. |
| 385 | ApprovalWidget::new(&self.request, self).inline_region(area) |
| 386 | } |
| 387 | |
| 388 | fn tick(&mut self) -> ViewAction { |
| 389 | if self.is_timed_out() { |
| 390 | return self.emit_decision(ReviewDecision::Denied, true); |
| 391 | } |
| 392 | ViewAction::None |
| 393 | } |
| 394 | } |
| 395 |