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