返回 CodeWhale
view.rs
根目录 / crates / tui / src / tui / approval / view.rs
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
468 lines RUST