返回 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::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
395 lines RUST