返回 CodeWhale
approval_band_legacy.rs
根目录 / crates / tui / src / tui / widgets / approval_band_legacy.rs
1 // Frozen original approval band and helpers; private test counterpart only.
2 use super::*;
3 /// Compact, bottom-anchored approval card.
4 ///
5 /// The widget reads its selected option and locale directly from the
6 /// [`ApprovalView`]. Rendering preserves transcript context while reserving
7 /// the complete action set and at least one load-bearing command/preview row
8 /// on ordinary terminal sizes.
9 pub struct ApprovalWidget<'a> {
10 request: &'a ApprovalRequest,
11 view: &'a ApprovalView,
12 }
13
14 impl<'a> ApprovalWidget<'a> {
15 pub fn new(request: &'a ApprovalRequest, view: &'a ApprovalView) -> Self {
16 Self { request, view }
17 }
18
19 /// Build the inline approval content, split into the informational `body`
20 /// (which may scroll/truncate within its region) and the interactive
21 /// `controls` (which are always reserved and can never be clipped). Both
22 /// `render` and `inline_region` use this so the painted band and the
23 /// dimmed backdrop region always agree.
24 ///
25 /// The save preview says what a persistent rule would cover while the
26 /// controls offer to save it, so it is never dropped: it is a trust
27 /// boundary, not decoration. A band too short for the full preview gets
28 /// one line per rule instead of calling the request "truncated" (#6566).
29 /// The band always reserves the compact preview's rows and `render` pins
30 /// the preview above the controls, so a short band cuts the request
31 /// detail, never the preview. A frame too small (or too narrow) for even
32 /// the one-line preview fails closed: the card drops the preview and the
33 /// save offers together (`[p]`, `s`), keeping only one-off decisions.
34 fn build_inline_content(&self, area: Rect) -> InlineContent {
35 let (compact, save_start, controls) = self.build_inline_parts(area, true, true);
36 let save_reserve = measure_wrapped_rows(&compact[save_start..], area.width);
37 let compact = InlineContent {
38 body: compact,
39 save_start,
40 save_reserve,
41 controls,
42 save_shown: true,
43 };
44 if save_start == compact.body.len() {
45 return InlineContent {
46 save_shown: false,
47 ..compact
48 };
49 }
50 if !compact.save_preview_fits(area) {
51 let (body, save_start, controls) = self.build_inline_parts(area, true, false);
52 return InlineContent {
53 body,
54 save_start,
55 save_reserve: 0,
56 controls,
57 save_shown: false,
58 };
59 }
60 let (body, save_start, controls) = self.build_inline_parts(area, false, true);
61 let full = InlineContent {
62 body,
63 save_start,
64 save_reserve,
65 controls,
66 save_shown: true,
67 };
68 if full.body_fits(area) { full } else { compact }
69 }
70
71 /// The body, how many of its leading lines come before the save preview,
72 /// and the controls. `compact_save_preview` puts each rule on one line;
73 /// without `offer_save` there is neither a save preview nor a save offer.
74 fn build_inline_parts(
75 &self,
76 area: Rect,
77 compact_save_preview: bool,
78 offer_save: bool,
79 ) -> (Vec<Line<'static>>, usize, Vec<Line<'static>>) {
80 let risk = self.request.risk;
81 let stakes = self.request.stakes();
82 let locale = self.view.locale();
83 let repo_law = self.request.is_repo_law_prompt();
84 let palette_colors = if repo_law {
85 repo_law_approval_palette()
86 } else {
87 approval_palette(stakes)
88 };
89 let critical = matches!(stakes, crate::tui::approval::ApprovalStakes::Critical);
90
91 let mut body: Vec<Line<'static>> = Vec::with_capacity(16);
92 // Header: effect badge + the plain summary of the call (E6). The raw
93 // tool name stays one details chord away in the pager.
94 body.push(Line::from(vec![
95 Span::raw(" "),
96 Span::styled(
97 format!(
98 " {} ",
99 if repo_law {
100 tr(locale, MessageId::ApprovalRepoLawBadge)
101 } else {
102 effect_badge_text(self.request, stakes, locale)
103 }
104 ),
105 Style::default()
106 .fg(palette::WHALE_BG)
107 .bg(palette_colors.accent)
108 .add_modifier(Modifier::BOLD),
109 ),
110 Span::raw(" "),
111 Span::styled(
112 if repo_law {
113 format!(
114 "{} · {}",
115 tr(locale, MessageId::ApprovalRepoLawTitle),
116 approval_heading(self.request, locale)
117 )
118 } else {
119 approval_heading(self.request, locale)
120 },
121 Style::default()
122 .fg(palette::WHALE_ACTION)
123 .add_modifier(Modifier::BOLD),
124 ),
125 ]));
126
127 // A child's card names the agent that is waiting (approvals C1).
128 if let Some(owner) = self.request.owner.as_ref() {
129 body.push(Line::from(vec![
130 Span::raw(" "),
131 Span::styled(
132 approval_owner_header(owner, locale),
133 Style::default()
134 .fg(palette::TEXT_SECONDARY)
135 .add_modifier(Modifier::BOLD),
136 ),
137 ]));
138 }
139
140 if repo_law {
141 body.push(Line::from(vec![
142 Span::raw(" "),
143 Span::styled(
144 "◆ ",
145 Style::default()
146 .fg(palette::STATUS_WARNING)
147 .add_modifier(Modifier::BOLD),
148 ),
149 Span::styled(
150 tr(locale, MessageId::ApprovalRepoLawWarning),
151 Style::default()
152 .fg(palette::WHALE_ERROR)
153 .add_modifier(Modifier::BOLD),
154 ),
155 ]));
156 body.push(Line::from(vec![
157 Span::raw(" "),
158 Span::styled(
159 tr(locale, MessageId::ApprovalRepoLawRuleLabel),
160 Style::default().fg(palette::TEXT_HINT),
161 ),
162 Span::styled(
163 self.request.description.clone(),
164 Style::default().fg(palette::TEXT_SECONDARY),
165 ),
166 ]));
167 }
168
169 // Command / change preview FIRST — for an approval the thing being run
170 // is the load-bearing content, so on a short terminal it is the
171 // secondary context (about/impacts/category) that scrolls away, never
172 // the command.
173 let details = self.request.prominent_detail_items(locale);
174 if details.is_empty() {
175 push_params_detail_line(&mut body, self.request, locale, area.width);
176 } else {
177 let mut rendered_detail = false;
178 for detail in details.iter().take(4) {
179 let is_change_preview = matches!(detail.label.as_str(), "Preview" | "预览");
180 if let Some(shell_lines) = detail.shell_lines.as_deref() {
181 let command_width = area.width.saturating_sub(10) as usize;
182 // A short approval band has room for only one detail row
183 // before its truncation hint. Project the most useful
184 // command/change into that row instead of spending it on
185 // setup (`cd`, `set`) or diff metadata. The complete,
186 // original-order value remains available in the details
187 // pager.
188 let inline_shell_lines = prioritize_inline_shell_lines(
189 shell_lines,
190 is_change_preview,
191 area.height <= 24,
192 );
193 // Bound every multi-line preview so one huge command cannot
194 // grow the band without limit; the details chord opens the rest.
195 let max_rows = if is_change_preview {
196 if self.request.intent_summary.is_some() {
197 Some(3)
198 } else {
199 Some(5)
200 }
201 } else {
202 Some(8)
203 };
204 push_shell_command_lines(
205 &mut body,
206 &detail.label,
207 &inline_shell_lines,
208 command_width.max(20),
209 max_rows,
210 );
211 } else {
212 push_detail_line(&mut body, &detail.label, &detail.value);
213 }
214 rendered_detail = true;
215 }
216 if !rendered_detail {
217 push_params_detail_line(&mut body, self.request, locale, area.width);
218 }
219 }
220
221 // Intent summary ("why this change is needed", #2381).
222 if let Some(ref summary) = self.request.intent_summary {
223 let max_width = area.width.saturating_sub(14) as usize;
224 if max_width > 0 {
225 let intent_label = tr(locale, MessageId::ApprovalIntentLabel);
226 let summary_lines: Vec<&str> = summary.lines().collect();
227 let intent_lines = 3usize;
228 for (i, sline) in summary_lines.iter().take(intent_lines).enumerate() {
229 let prefix = if i == 0 {
230 intent_label.clone()
231 } else {
232 Cow::Borrowed(" ")
233 };
234 let truncated = crate::utils::truncate_with_ellipsis(sline, max_width, "...");
235 body.push(Line::from(vec![
236 Span::raw(" "),
237 Span::styled(
238 prefix,
239 if i == 0 {
240 Style::default().fg(palette::TEXT_HINT)
241 } else {
242 Style::default()
243 },
244 ),
245 Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
246 ]));
247 }
248 if summary_lines.len() > intent_lines {
249 let more = tr(locale, MessageId::ApprovalMoreLines)
250 .replace("{count}", &(summary_lines.len() - intent_lines).to_string());
251 body.push(Line::from(vec![
252 Span::raw(" "),
253 Span::styled(more, Style::default().fg(palette::TEXT_HINT)),
254 ]));
255 }
256 }
257 }
258
259 // Destructive policy / cancel semantics — critical stakes only. For
260 // routine and elevated work the controls speak for themselves; the
261 // extra policy prose was noise that made every edit read like an
262 // emergency.
263 // The semantics prose says Esc stops the turn; a child's card hides
264 // on Esc instead, so it never shows that line.
265 if critical && self.request.owner.is_none() {
266 push_destructive_approval_semantics(&mut body, locale, false);
267 }
268
269 // Secondary context: what it is and what it touches. Only critical
270 // prompts carry the full about/impact/category dossier by default —
271 // everything stays one details chord away in the pager. Keep a single
272 // About line as fallback context when nothing else was rendered.
273 if critical || details.is_empty() {
274 body.push(Line::from(vec![
275 Span::raw(" "),
276 Span::styled(label_about(locale), Style::default().fg(palette::TEXT_HINT)),
277 Span::styled(
278 self.request.description_for_locale(locale),
279 Style::default().fg(palette::TEXT_BODY),
280 ),
281 ]));
282 }
283 if critical {
284 for impact in self.request.impacts_for_locale(locale).into_iter().take(4) {
285 body.push(Line::from(vec![
286 Span::raw(" "),
287 Span::styled(
288 label_impact(locale),
289 Style::default().fg(palette::TEXT_HINT),
290 ),
291 Span::styled(impact, Style::default().fg(palette::TEXT_BODY)),
292 ]));
293 }
294 // Category line — localized risk category.
295 let (cat_label, cat_color) = category_label_for(self.request, locale);
296 body.push(Line::from(vec![
297 Span::raw(" "),
298 Span::styled(label_type(locale), Style::default().fg(palette::TEXT_HINT)),
299 Span::styled(
300 cat_label,
301 Style::default().fg(cat_color).add_modifier(Modifier::BOLD),
302 ),
303 ]));
304 }
305
306 // Preview the validated persistent-rule candidates. Informational, so
307 // they live in the scrollable body rather than the action rows.
308 let essential_len = body.len();
309 if let Some(preview) = self.request.ask_rule_save_preview().filter(|_| offer_save) {
310 push_permission_rule_save_preview(
311 &mut body,
312 &preview,
313 palette_colors.shortcut,
314 area.width,
315 compact_save_preview,
316 );
317 }
318 if let Some(preview) = self
319 .request
320 .allow_rule_save_preview()
321 .filter(|_| offer_save)
322 {
323 push_permission_rule_save_preview(
324 &mut body,
325 &preview,
326 palette_colors.shortcut,
327 area.width,
328 compact_save_preview,
329 );
330 }
331
332 let controls = build_approval_controls(
333 self.request,
334 self.view,
335 risk,
336 locale,
337 palette_colors.accent,
338 palette_colors.shortcut,
339 offer_save,
340 );
341 (body, essential_len, controls)
342 }
343
344 /// Bottom-anchored band this inline prompt occupies within `area`. Must
345 /// match what `render` paints so the backdrop dims exactly this strip.
346 pub(crate) fn inline_region(&self, area: Rect) -> Rect {
347 if area.width == 0 || area.height == 0 {
348 return Rect {
349 x: area.x,
350 y: area.y.saturating_add(area.height),
351 width: 0,
352 height: 0,
353 };
354 }
355 if self.view.collapsed {
356 // Collapsed mode is a single banner row pinned to the bottom.
357 let h = area.height.min(1);
358 return Rect {
359 x: area.x,
360 y: area.y.saturating_add(area.height.saturating_sub(h)),
361 width: area.width,
362 height: h,
363 };
364 }
365 self.build_inline_content(area).region(area)
366 }
367 }
368
369 impl Renderable for ApprovalWidget<'_> {
370 fn render(&self, area: Rect, buf: &mut Buffer) {
371 if area.width == 0 || area.height == 0 {
372 return;
373 }
374
375 // Collapsed mode: a single-line banner at the bottom of the area
376 // so the user can still see the transcript behind it.
377 if self.view.collapsed {
378 self.view.set_mouse_hitboxes(Vec::new());
379 self.view.set_save_preview_shown(false);
380 let bar_y = area.y.saturating_add(area.height.saturating_sub(1));
381 let bar_area = Rect::new(area.x, bar_y, area.width, 1);
382 Clear.render(bar_area, buf);
383
384 let stakes = self.request.stakes();
385 let repo_law = self.request.is_repo_law_prompt();
386 let palette_colors = if repo_law {
387 repo_law_approval_palette()
388 } else {
389 approval_palette(stakes)
390 };
391 let summary = format!(
392 " {} — {} [Tab to expand] ",
393 if repo_law {
394 tr(self.view.locale(), MessageId::ApprovalRepoLawTitle)
395 } else {
396 Cow::Owned(approval_heading(self.request, self.view.locale()))
397 },
398 if repo_law {
399 tr(self.view.locale(), MessageId::ApprovalRepoLawBadge)
400 } else {
401 effect_badge_text(self.request, stakes, self.view.locale())
402 },
403 );
404 let line = Line::from(Span::styled(
405 summary,
406 Style::default()
407 .fg(palette::WHALE_BG)
408 .bg(palette_colors.accent)
409 .add_modifier(Modifier::BOLD),
410 ));
411 Paragraph::new(line).render(bar_area, buf);
412 return;
413 }
414
415 // Compute stakes once for this render pass (it runs command_safety
416 // analysis on shell commands); reuse it for the palette and the
417 // left-rail gate instead of re-deriving per band.
418 let stakes = self.request.stakes();
419 let repo_law = self.request.is_repo_law_prompt();
420 let palette_colors = if repo_law {
421 repo_law_approval_palette()
422 } else {
423 approval_palette(stakes)
424 };
425 let content = self.build_inline_content(area);
426 let region = content.region(area);
427 let InlineContent {
428 body,
429 save_start,
430 controls,
431 save_shown,
432 ..
433 } = content;
434 self.view.set_save_preview_shown(false);
435 if region.width == 0 || region.height == 0 {
436 return;
437 }
438 self.view.set_save_preview_shown(save_shown);
439
440 // Opaque inline panel anchored to the bottom of the frame. The
441 // transcript above stays visible; only this band is painted — the
442 // approval is no longer a full-screen takeover (#3799).
443 Clear.render(region, buf);
444 Block::default()
445 .style(Style::default().bg(palette::WHALE_BG))
446 .render(region, buf);
447
448 // Top separator rule, risk-tinted, so the prompt reads as a distinct
449 // panel without a heavy full border box.
450 let rule_glyph = if repo_law { "═" } else { "─" };
451 let rule: String = rule_glyph.repeat(region.width as usize);
452 buf.set_string(
453 region.x,
454 region.y,
455 &rule,
456 Style::default().fg(palette_colors.border),
457 );
458
459 // Reserve the controls FIRST: they take their rows off the bottom of
460 // the band and can never be clipped, no matter how long the body is.
461 // The informational body takes whatever remains and shows a pager
462 // affordance when it does not fit. This is the core #3799 fix — the
463 // action row is no longer the last thing in a single clipping
464 // Paragraph.
465 let inner_top = region.y.saturating_add(1);
466 let inner_height = region.height.saturating_sub(1);
467 let control_rows = measure_wrapped_rows(&controls, region.width).min(inner_height);
468 let body_height = inner_height.saturating_sub(control_rows);
469
470 let body_rect = Rect {
471 x: region.x,
472 y: inner_top,
473 width: region.width,
474 height: body_height,
475 };
476 let control_rect = Rect {
477 x: region.x,
478 y: inner_top.saturating_add(body_height),
479 width: region.width,
480 height: control_rows,
481 };
482
483 // One hitbox per option in `ApprovalOption` order; an option the card
484 // is not offering keeps an empty box so the indices stay aligned.
485 let mut hitboxes = Vec::new();
486 let options =
487 approval_options_for_request(self.request, self.request.risk, self.view.locale());
488 let mut shown_index = 0;
489 for option in &options {
490 if option.persistent && !save_shown {
491 hitboxes.push(Rect::default());
492 continue;
493 }
494 let first_line = 1 + shown_index;
495 shown_index += 1;
496 let y_offset = measure_wrapped_rows(&controls[..first_line], region.width);
497 let next_offset = measure_wrapped_rows(&controls[..first_line + 1], region.width);
498 let y = control_rect.y.saturating_add(y_offset);
499 let height = next_offset.saturating_sub(y_offset).min(
500 control_rect
501 .y
502 .saturating_add(control_rect.height)
503 .saturating_sub(y),
504 );
505 if height > 0 {
506 hitboxes.push(Rect::new(control_rect.x, y, control_rect.width, height));
507 }
508 }
509 self.view.set_mouse_hitboxes(hitboxes);
510
511 let body_rows = measure_wrapped_rows(&body, region.width);
512 if body_rows > body_height && body_height > 0 {
513 // Body does not fit (short terminal). The save preview is pinned
514 // directly above the controls that offer to save it; the request
515 // detail above it shows as much as fits and points at the params
516 // pager through the platform-aware details chord.
517 let mut body = body;
518 let save = body.split_off(save_start.min(body.len()));
519 let save_rows = measure_wrapped_rows(&save, region.width).min(body_height);
520 let head_height = body_height.saturating_sub(save_rows);
521 if head_height > 0 {
522 let shown = head_height.saturating_sub(1);
523 if shown > 0 {
524 Paragraph::new(body).wrap(Wrap { trim: false }).render(
525 Rect {
526 height: shown,
527 ..body_rect
528 },
529 buf,
530 );
531 }
532 buf.set_string(
533 region.x,
534 body_rect.y.saturating_add(shown),
535 approval_truncation_hint(self.view.locale()),
536 Style::default().fg(palette::TEXT_HINT),
537 );
538 }
539 if save_rows > 0 {
540 Paragraph::new(save).wrap(Wrap { trim: false }).render(
541 Rect {
542 y: body_rect.y.saturating_add(head_height),
543 height: save_rows,
544 ..body_rect
545 },
546 buf,
547 );
548 }
549 } else {
550 Paragraph::new(body)
551 .wrap(Wrap { trim: false })
552 .render(body_rect, buf);
553 }
554
555 Paragraph::new(controls)
556 .wrap(Wrap { trim: false })
557 .render(control_rect, buf);
558 }
559
560 fn desired_height(&self, _width: u16) -> u16 {
561 1
562 }
563 }
564
565 /// The inline approval band's lines. `body[save_start..]` is the
566 /// persistent-rule save preview; `save_reserve` is the rows its one-line
567 /// form needs, which the band always keeps for it. `save_shown` says the
568 /// preview is on screen, and with it the offers to save the rule.
569 struct InlineContent {
570 body: Vec<Line<'static>>,
571 save_start: usize,
572 save_reserve: u16,
573 controls: Vec<Line<'static>>,
574 save_shown: bool,
575 }
576
577 impl InlineContent {
578 fn region(&self, area: Rect) -> Rect {
579 inline_region_for(area, &self.body, self.save_reserve, &self.controls)
580 }
581
582 /// Whether the band keeps the whole one-line save preview on screen
583 /// above the controls (render pins it there when the body is cut).
584 fn save_preview_fits(&self, area: Rect) -> bool {
585 let region = self.region(area);
586 let inner_height = region.height.saturating_sub(1);
587 let control_rows = measure_wrapped_rows(&self.controls, region.width).min(inner_height);
588 self.save_reserve <= inner_height.saturating_sub(control_rows)
589 }
590
591 /// Whether the whole body fits the band above the controls.
592 fn body_fits(&self, area: Rect) -> bool {
593 let region = self.region(area);
594 let inner_height = region.height.saturating_sub(1);
595 let control_rows = measure_wrapped_rows(&self.controls, region.width).min(inner_height);
596 measure_wrapped_rows(&self.body, region.width) <= inner_height.saturating_sub(control_rows)
597 }
598 }
599
600 /// Bottom-anchored band the inline approval prompt occupies within `area`.
601 /// Sized to the measured content, capped to half the frame like the compact
602 /// permission surfaces in peer coding agents, and always tall enough to show
603 /// the reserved controls (#3799). Full details remain available through the
604 /// platform-aware details chord.
605 ///
606 /// `save_rows` are the rows of the one-line persistent-rule save preview.
607 /// They are always reserved after the controls, on every frame height,
608 /// because the controls offer to save that rule and the person must see what
609 /// it covers.
610 fn inline_region_for(
611 area: Rect,
612 body: &[Line<'static>],
613 save_rows: u16,
614 controls: &[Line<'static>],
615 ) -> Rect {
616 if area.width == 0 || area.height == 0 {
617 return Rect {
618 x: area.x,
619 y: area.y.saturating_add(area.height),
620 width: 0,
621 height: 0,
622 };
623 }
624 let width = area.width;
625 let body_rows = measure_wrapped_rows(body, width);
626 let control_rows = measure_wrapped_rows(controls, width);
627 // +1 for the top separator rule.
628 let desired = 1u16.saturating_add(body_rows).saturating_add(control_rows);
629 // Never shrink below the rule + controls. At normal terminal heights,
630 // reserve four body rows: header, detail label, at least one command or
631 // preview row, and the truncation hint. Half a viewport is the preferred
632 // cap; up to four fifths is allowed only when necessary to retain that
633 // load-bearing preview on a short frame. The extra permanent-grant row
634 // needs one more reserved line than the legacy four-action card. Truly
635 // tiny frames prioritize the complete action set and details chord.
636 let controls_floor = 1u16.saturating_add(control_rows).min(area.height);
637 // The request's own preview (what runs now) and the save preview (what a
638 // saved rule would cover from now on) are reserved side by side: neither
639 // may push the other off a short band.
640 let head_rows = body_rows.saturating_sub(save_rows);
641 let preview_rows = if area.height >= 16 {
642 head_rows.min(4).saturating_add(save_rows)
643 } else {
644 save_rows
645 };
646 let preview_floor = controls_floor.saturating_add(preview_rows).min(area.height);
647 let preferred_cap = area.height.div_ceil(2);
648 let short_frame_cap = area.height.saturating_mul(4).div_ceil(5);
649 // The save preview is never traded for the short-frame cap: whenever the
650 // frame has rows after the controls, the preview gets them first.
651 let save_floor = controls_floor.saturating_add(save_rows).min(area.height);
652 let max_height = preferred_cap
653 .max(preview_floor.min(short_frame_cap.saturating_add(save_rows)))
654 .max(save_floor)
655 .min(area.height);
656 let min_height = controls_floor;
657 let height = desired.clamp(min_height, max_height);
658 Rect {
659 x: area.x,
660 y: area.y.saturating_add(area.height.saturating_sub(height)),
661 width,
662 height,
663 }
664 }
665
666 /// Terminal rows `lines` occupy under the exact ratatui word-wrap used by the
667 /// renderer. Exact measurement keeps localized controls and their mouse
668 /// hitboxes aligned without padding the compact approval band.
669 fn measure_wrapped_rows(lines: &[Line<'_>], width: u16) -> u16 {
670 if width == 0 {
671 return lines.len() as u16;
672 }
673 let rows = Paragraph::new(lines.to_vec())
674 .wrap(Wrap { trim: false })
675 .line_count(width);
676 u16::try_from(rows).unwrap_or(u16::MAX)
677 }
678
679 /// Build the always-visible approval controls: a "proceed?" prompt, the
680 /// numbered/selectable options, and the selection hint. Rendered into a region
681 /// reserved off the bottom of the band so it can never be clipped (#3799).
682 fn build_approval_controls(
683 request: &ApprovalRequest,
684 view: &ApprovalView,
685 risk: RiskLevel,
686 locale: Locale,
687 accent: Color,
688 shortcut: Color,
689 offer_save: bool,
690 ) -> Vec<Line<'static>> {
691 let mut controls: Vec<Line<'static>> = Vec::with_capacity(6);
692 controls.push(Line::from(vec![
693 Span::raw(" "),
694 Span::styled(
695 approval_proceed_question(locale),
696 Style::default()
697 .fg(palette::TEXT_BODY)
698 .add_modifier(Modifier::BOLD),
699 ),
700 ]));
701 let options = approval_options_for_request(request, risk, locale);
702 for (i, opt) in options.iter().enumerate() {
703 if opt.persistent && !offer_save {
704 continue;
705 }
706 let is_selected = i == view.selected();
707 let label_color = if opt.dangerous {
708 accent
709 } else {
710 palette::TEXT_BODY
711 };
712 let option_style = approval_option_style(is_selected, label_color);
713 let shortcut_style = approval_option_style(is_selected, shortcut);
714 // Leading caret marks the row Enter will fire — selection is not
715 // signalled by background alone.
716 let lead = if is_selected {
717 Span::styled("\u{276f} ", approval_selected_style())
718 } else {
719 Span::raw(" ")
720 };
721 controls.push(Line::from(vec![
722 lead,
723 Span::styled(
724 format!("[{}] ", opt.key_hint),
725 shortcut_style.add_modifier(Modifier::BOLD),
726 ),
727 Span::styled(opt.label.to_string(), option_style),
728 ]));
729 }
730 controls.push(Line::from(vec![
731 Span::raw(" "),
732 Span::styled(
733 if request.owner.is_some() {
734 child_footer_controls(locale)
735 } else {
736 footer_controls(locale)
737 },
738 Style::default().fg(palette::TEXT_MUTED),
739 ),
740 if offer_save && request.can_save_ask_rule() {
741 Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut))
742 } else {
743 Span::raw("")
744 },
745 ]));
746 controls
747 }
748
749 fn approval_proceed_question(locale: Locale) -> &'static str {
750 match locale {
751 Locale::ZhHans => "是否继续?",
752 _ => "Do you want to proceed?",
753 }
754 }
755
756 fn approval_truncation_hint(locale: Locale) -> Cow<'static, str> {
757 let details = crate::tui::shell_key_routing::tool_details_chord();
758 Cow::Owned(tr(locale, MessageId::ApprovalTruncationHint).replace("{details}", details.as_ref()))
759 }
760
761 /// Approval palette per risk variant.
762 struct ApprovalColors {
763 border: Color,
764 accent: Color,
765 shortcut: Color,
766 }
767
768 fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalColors {
769 use crate::tui::approval::ApprovalStakes;
770 match stakes {
771 ApprovalStakes::Routine => ApprovalColors {
772 border: palette::BORDER_COLOR,
773 accent: palette::WHALE_HUMAN,
774 shortcut: palette::WHALE_ACTION,
775 },
776 // Ordinary state-touching work: a calm ask, not an alarm.
777 ApprovalStakes::Elevated => ApprovalColors {
778 border: palette::WHALE_HUMAN,
779 accent: palette::WHALE_HUMAN,
780 shortcut: palette::WHALE_ACTION,
781 },
782 ApprovalStakes::Critical => ApprovalColors {
783 border: palette::WHALE_ERROR,
784 accent: palette::WHALE_ERROR,
785 shortcut: palette::STATUS_WARNING,
786 },
787 }
788 }
789
790 fn repo_law_approval_palette() -> ApprovalColors {
791 ApprovalColors {
792 border: palette::STATUS_WARNING,
793 accent: palette::WHALE_ERROR,
794 shortcut: palette::STATUS_WARNING,
795 }
796 }
797
798 fn approval_selected_style() -> Style {
799 menu_style::selected_row_style()
800 }
801
802 fn approval_option_style(is_selected: bool, color: Color) -> Style {
803 if is_selected {
804 approval_selected_style()
805 } else {
806 Style::default().fg(color)
807 }
808 }
809
810 /// The approval card's heading: the plain summary of the call (E6), in the
811 /// card's language, falling back to the tool name only when no summary was
812 /// derived.
813 fn approval_heading(request: &ApprovalRequest, locale: Locale) -> String {
814 if request.summary.trim().is_empty() {
815 return request.tool_name.clone();
816 }
817 let summary = request.summary_for_locale(locale);
818 if summary.trim().is_empty() {
819 request.tool_name.clone()
820 } else {
821 summary
822 }
823 }
824
825 /// Badge naming what the call does, not a risk tier: "Reads only", "Changes
826 /// files", "Runs a command", "Uses the network". Anything the stakes
827 /// classifier calls destructive or publishing reads "Can't be undone".
828 fn effect_badge_text(
829 request: &ApprovalRequest,
830 stakes: crate::tui::approval::ApprovalStakes,
831 locale: Locale,
832 ) -> Cow<'static, str> {
833 if stakes == crate::tui::approval::ApprovalStakes::Critical {
834 return tr(locale, MessageId::ApprovalRiskDestructive);
835 }
836 let id = match request.category {
837 ToolCategory::Safe | ToolCategory::McpRead => MessageId::ApprovalEffectReadsOnly,
838 ToolCategory::FileWrite => MessageId::ApprovalEffectChangesFiles,
839 ToolCategory::Shell => MessageId::ApprovalEffectRunsCommand,
840 ToolCategory::Network => MessageId::ApprovalEffectUsesNetwork,
841 ToolCategory::McpAction => MessageId::ApprovalEffectConnectedApp,
842 ToolCategory::Agent => MessageId::ApprovalEffectStartsAgent,
843 ToolCategory::Unknown => MessageId::ApprovalEffectUnclassified,
844 };
845 tr(locale, id)
846 }
847
848 fn category_label_for(request: &ApprovalRequest, locale: Locale) -> (Cow<'static, str>, Color) {
849 let category = request.category;
850 let label = match category {
851 ToolCategory::Safe => tr(locale, MessageId::ApprovalCategorySafe),
852 ToolCategory::FileWrite => tr(locale, MessageId::ApprovalCategoryFileWrite),
853 ToolCategory::Shell => tr(locale, MessageId::ApprovalCategoryShell),
854 ToolCategory::Network => tr(locale, MessageId::ApprovalCategoryNetwork),
855 ToolCategory::McpRead => tr(locale, MessageId::ApprovalCategoryMcpRead),
856 ToolCategory::McpAction => tr(locale, MessageId::ApprovalCategoryMcpAction),
857 ToolCategory::Agent => tr(locale, MessageId::ApprovalCategoryAgent),
858 ToolCategory::Unknown => tr(locale, MessageId::ApprovalCategoryUnknown),
859 };
860 // "Connected app (github)": name the server the tool comes from.
861 let label = match (
862 category,
863 crate::tui::approval::connected_app_server(&request.tool_name),
864 ) {
865 (ToolCategory::McpRead | ToolCategory::McpAction, Some(server)) => {
866 Cow::Owned(format!("{label} ({server})"))
867 }
868 _ => label,
869 };
870 let color = match category {
871 ToolCategory::Safe => palette::STATUS_SUCCESS,
872 ToolCategory::FileWrite => palette::STATUS_WARNING,
873 ToolCategory::Shell => palette::STATUS_ERROR,
874 ToolCategory::Network => palette::STATUS_WARNING,
875 ToolCategory::McpRead => palette::WHALE_ACTION,
876 ToolCategory::McpAction => palette::STATUS_WARNING,
877 ToolCategory::Agent => palette::WHALE_ACTION,
878 ToolCategory::Unknown => palette::STATUS_ERROR,
879 };
880 (label, color)
881 }
882
883 fn label_type(locale: Locale) -> Cow<'static, str> {
884 tr(locale, MessageId::ApprovalFieldType)
885 }
886
887 fn label_about(locale: Locale) -> Cow<'static, str> {
888 tr(locale, MessageId::ApprovalFieldAbout)
889 }
890
891 fn label_impact(locale: Locale) -> Cow<'static, str> {
892 tr(locale, MessageId::ApprovalFieldImpact)
893 }
894
895 fn label_params(locale: Locale) -> Cow<'static, str> {
896 tr(locale, MessageId::ApprovalFieldParams)
897 }
898
899 fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) {
900 lines.push(Line::from(vec![
901 Span::raw(" "),
902 Span::styled(
903 format!("{label:<7} "),
904 Style::default()
905 .fg(palette::WHALE_ACTION)
906 .add_modifier(Modifier::BOLD),
907 ),
908 Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)),
909 ]));
910 }
911
912 fn push_params_detail_line(
913 lines: &mut Vec<Line<'static>>,
914 request: &ApprovalRequest,
915 locale: Locale,
916 card_width: u16,
917 ) {
918 let params_str = request.params_display();
919 let params_width = card_width.saturating_sub(14) as usize;
920 let params_truncated =
921 crate::utils::truncate_with_ellipsis(&params_str, params_width.max(20), "...");
922 lines.push(Line::from(vec![
923 Span::raw(" "),
924 Span::styled(
925 label_params(locale),
926 Style::default().fg(palette::TEXT_HINT),
927 ),
928 Span::styled(
929 params_truncated,
930 Style::default().fg(palette::TEXT_SECONDARY),
931 ),
932 ]));
933 }
934
935 fn push_permission_rule_save_preview(
936 lines: &mut Vec<Line<'static>>,
937 preview: &crate::tui::approval::PermissionRuleSavePreview,
938 shortcut: Color,
939 card_width: u16,
940 compact: bool,
941 ) {
942 if compact {
943 // One line: what saving does, then what it covers, with the count of
944 // entries that did not fit kept visible after any ellipsis.
945 let summary = preview.summary();
946 let more = if preview.omitted > 0 {
947 format!(" +{} more", preview.omitted)
948 } else {
949 String::new()
950 };
951 let budget = (card_width as usize)
952 .saturating_sub(10 + summary.chars().count() + 3 + more.chars().count())
953 .max(12);
954 let entries =
955 crate::utils::truncate_with_ellipsis(&preview.entries.join("; "), budget, "...");
956 lines.push(Line::from(vec![
957 Span::raw(" "),
958 Span::styled(
959 "Save: ",
960 Style::default().fg(shortcut).add_modifier(Modifier::BOLD),
961 ),
962 Span::styled(summary, Style::default().fg(palette::TEXT_BODY)),
963 Span::styled(
964 format!(" · {entries}{more}"),
965 Style::default().fg(palette::TEXT_SECONDARY),
966 ),
967 ]));
968 return;
969 }
970 lines.push(Line::from(vec![
971 Span::raw(" "),
972 Span::styled(
973 "Save: ",
974 Style::default().fg(shortcut).add_modifier(Modifier::BOLD),
975 ),
976 Span::styled(preview.summary(), Style::default().fg(palette::TEXT_BODY)),
977 ]));
978
979 let entry_width = card_width.saturating_sub(10) as usize;
980 let entries = preview.entries.join("; ");
981 let truncated = crate::utils::truncate_with_ellipsis(&entries, entry_width.max(20), "...");
982 lines.push(Line::from(vec![
983 Span::raw(" "),
984 Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
985 ]));
986 if preview.omitted > 0 {
987 lines.push(Line::from(vec![
988 Span::raw(" "),
989 Span::styled(
990 format!("... {} more", preview.omitted),
991 Style::default().fg(palette::TEXT_HINT),
992 ),
993 ]));
994 }
995 }
996
997 fn push_shell_command_lines(
998 lines: &mut Vec<Line<'static>>,
999 label: &str,
1000 command_lines: &[String],
1001 command_width: usize,
1002 max_rows: Option<usize>,
1003 ) {
1004 lines.push(Line::from(vec![
1005 Span::raw(" "),
1006 Span::styled(
1007 format!("{label}:"),
1008 Style::default()
1009 .fg(palette::WHALE_ACTION)
1010 .add_modifier(Modifier::BOLD),
1011 ),
1012 ]));
1013
1014 let mut rendered = 0usize;
1015 for line in command_lines {
1016 for wrapped in wrap_text(line, command_width) {
1017 if max_rows.is_some_and(|limit| rendered >= limit) {
1018 lines.push(Line::from(vec![
1019 Span::raw(" "),
1020 Span::styled(
1021 "...",
1022 Style::default()
1023 .fg(palette::TEXT_HINT)
1024 .add_modifier(Modifier::BOLD),
1025 ),
1026 ]));
1027 return;
1028 }
1029 lines.push(Line::from(vec![
1030 Span::raw(" "),
1031 Span::styled(
1032 wrapped,
1033 Style::default()
1034 .fg(palette::TEXT_BODY)
1035 .add_modifier(Modifier::BOLD),
1036 ),
1037 ]));
1038 rendered += 1;
1039 }
1040 }
1041 }
1042
1043 /// Put one representative command/change first for compact inline rendering.
1044 /// This is a display-only projection: approval parameters and the details
1045 /// pager retain the exact original order.
1046 fn prioritize_inline_shell_lines(
1047 command_lines: &[String],
1048 is_change_preview: bool,
1049 compact: bool,
1050 ) -> Vec<String> {
1051 if !compact || command_lines.len() < 2 {
1052 return command_lines.to_vec();
1053 }
1054
1055 let representative = if is_change_preview {
1056 command_lines
1057 .iter()
1058 .enumerate()
1059 .max_by_key(|(index, line)| (preview_line_priority(line), std::cmp::Reverse(*index)))
1060 .map(|(index, _)| index)
1061 } else {
1062 command_lines
1063 .iter()
1064 .enumerate()
1065 .max_by_key(|(index, line)| (command_line_priority(line), std::cmp::Reverse(*index)))
1066 .map(|(index, _)| index)
1067 };
1068 let Some(representative) = representative.filter(|index| *index > 0) else {
1069 return command_lines.to_vec();
1070 };
1071
1072 let mut projected = Vec::with_capacity(command_lines.len());
1073 projected.push(command_lines[representative].clone());
1074 projected.extend(
1075 command_lines
1076 .iter()
1077 .enumerate()
1078 .filter(|(index, _)| *index != representative)
1079 .map(|(_, line)| line.clone()),
1080 );
1081 projected
1082 }
1083
1084 fn preview_line_priority(line: &str) -> u8 {
1085 let trimmed = line.trim_start();
1086 if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
1087 4
1088 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
1089 3
1090 } else if trimmed.starts_with("@@") {
1091 2
1092 } else if trimmed.starts_with("diff ")
1093 || trimmed.starts_with("---")
1094 || trimmed.starts_with("+++")
1095 {
1096 0
1097 } else {
1098 1
1099 }
1100 }
1101
1102 fn command_line_priority(line: &str) -> u8 {
1103 let trimmed = line.trim();
1104 if trimmed.is_empty() || trimmed.starts_with('#') {
1105 return 0;
1106 }
1107
1108 let tokens = trimmed
1109 .split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '(' | ')'))
1110 .filter(|token| !token.is_empty())
1111 .map(|token| token.rsplit('/').next().unwrap_or(token))
1112 .collect::<Vec<_>>();
1113 if tokens.iter().any(|token| {
1114 matches!(
1115 *token,
1116 "rm" | "rmdir"
1117 | "unlink"
1118 | "mv"
1119 | "dd"
1120 | "chmod"
1121 | "chown"
1122 | "kill"
1123 | "pkill"
1124 | "shutdown"
1125 | "reboot"
1126 | "mkfs"
1127 )
1128 }) || tokens.windows(2).any(|pair| {
1129 matches!(
1130 pair,
1131 ["git", "push"] | ["cargo", "publish"] | ["npm", "publish"]
1132 )
1133 }) || trimmed.contains('>')
1134 {
1135 return 4;
1136 }
1137
1138 let first = tokens.first().copied().unwrap_or_default();
1139 if matches!(
1140 first,
1141 "cd" | "pushd" | "popd" | "set" | "export" | "unset" | "pwd" | ":" | "true"
1142 ) {
1143 1
1144 } else if matches!(first, "echo" | "printf") {
1145 2
1146 } else {
1147 3
1148 }
1149 }
1150
1151 fn push_destructive_approval_semantics(
1152 lines: &mut Vec<Line<'static>>,
1153 locale: Locale,
1154 compact: bool,
1155 ) {
1156 if compact {
1157 let (label, value) = destructive_approval_compact_semantics(locale);
1158 lines.push(Line::from(vec![
1159 Span::raw(" "),
1160 Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
1161 Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
1162 ]));
1163 return;
1164 }
1165
1166 for (label, value) in destructive_approval_semantics(locale) {
1167 lines.push(Line::from(vec![
1168 Span::raw(" "),
1169 Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
1170 Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
1171 ]));
1172 }
1173 }
1174
1175 fn destructive_approval_compact_semantics(locale: Locale) -> (&'static str, &'static str) {
1176 match locale {
1177 Locale::ZhHans => ("规则: ", "批准策略要求确认;拒绝跳过本次,Esc 中止整轮。"),
1178 _ => (
1179 "Why: ",
1180 "Your permissions ask before this; d doesn't allow it, Esc stops the turn.",
1181 ),
1182 }
1183 }
1184
1185 fn destructive_approval_semantics(locale: Locale) -> [(&'static str, &'static str); 2] {
1186 match locale {
1187 Locale::ZhHans => [
1188 ("规则: ", "你的设置要求先确认这一步。"),
1189 ("取消: ", "拒绝只跳过本次工具调用;Esc 会中止整轮。"),
1190 ],
1191 _ => [
1192 ("Why: ", "Your settings ask you to confirm this step first."),
1193 (
1194 "Stop: ",
1195 "Don't allow skips only this step; Esc stops the whole turn.",
1196 ),
1197 ],
1198 }
1199 }
1200
1201 fn footer_controls(locale: Locale) -> Cow<'static, str> {
1202 // Platform-aware details chord (⌥V on macOS, Alt+V elsewhere). Bare `v`
1203 // is never advertised as a details shortcut (TUI-DOG-002).
1204 let details = crate::tui::shell_key_routing::tool_details_chord();
1205 Cow::Owned(tr(locale, MessageId::ApprovalControlsHint).replace("{details}", details.as_ref()))
1206 }
1207
1208 /// Controls hint for a child's card: Esc hides it, `g` opens the agent.
1209 fn child_footer_controls(locale: Locale) -> Cow<'static, str> {
1210 let details = crate::tui::shell_key_routing::tool_details_chord();
1211 Cow::Owned(format!(
1212 "{} · {}",
1213 tr(locale, MessageId::ApprovalControlsHintChild).replace("{details}", details.as_ref()),
1214 tr(locale, MessageId::ApprovalGoToAgent)
1215 ))
1216 }
1217
1218 /// "Agent: {agent} · {role}", dropping the role segment when the roster does
1219 /// not know the agent's role yet.
1220 fn approval_owner_header(owner: &crate::tui::approval::ApprovalOwner, locale: Locale) -> String {
1221 let template = tr(locale, MessageId::ApprovalOwnerHeader);
1222 let with_agent = template.replace("{agent}", &owner.label);
1223 match owner.role.as_deref() {
1224 Some(role) => with_agent.replace("{role}", role),
1225 None => with_agent
1226 .replace(" · {role}", "")
1227 .replace("{role}", "")
1228 .trim_end()
1229 .to_string(),
1230 }
1231 }
1232
1233 fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> {
1234 tr(locale, MessageId::ApprovalSaveAskRuleHint)
1235 }
1236
1237 #[derive(Clone)]
1238 struct ApprovalOptionRow {
1239 label: Cow<'static, str>,
1240 key_hint: &'static str,
1241 dangerous: bool,
1242 /// Saves a persistent rule: offered only beside its save preview.
1243 persistent: bool,
1244 }
1245
1246 fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] {
1247 let dangerous = matches!(risk, RiskLevel::Destructive);
1248 [
1249 ApprovalOptionRow {
1250 label: option_approve_once(locale),
1251 key_hint: "1 / y",
1252 dangerous,
1253 persistent: false,
1254 },
1255 ApprovalOptionRow {
1256 label: option_approve_always(locale),
1257 key_hint: "2 / a",
1258 dangerous,
1259 persistent: false,
1260 },
1261 ApprovalOptionRow {
1262 label: option_deny(locale),
1263 key_hint: "3 / d / n",
1264 dangerous: false,
1265 persistent: false,
1266 },
1267 ApprovalOptionRow {
1268 label: option_abort(locale),
1269 key_hint: "Esc",
1270 dangerous: false,
1271 persistent: false,
1272 },
1273 ]
1274 }
1275
1276 /// Workflow elevated-plan card options (#4126): Approve / Edit plan / Cancel.
1277 fn workflow_approval_options(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] {
1278 let dangerous = matches!(risk, RiskLevel::Destructive);
1279 [
1280 ApprovalOptionRow {
1281 label: workflow_option_approve(locale),
1282 key_hint: "1 / y",
1283 dangerous,
1284 persistent: false,
1285 },
1286 ApprovalOptionRow {
1287 label: workflow_option_edit_plan(locale),
1288 key_hint: "2 / e",
1289 dangerous: false,
1290 persistent: false,
1291 },
1292 ApprovalOptionRow {
1293 label: workflow_option_cancel(locale),
1294 key_hint: "3 / Esc",
1295 dangerous: false,
1296 persistent: false,
1297 },
1298 ]
1299 }
1300
1301 fn approval_options_for_request(
1302 request: &ApprovalRequest,
1303 risk: RiskLevel,
1304 locale: Locale,
1305 ) -> Vec<ApprovalOptionRow> {
1306 if request.tool_name == "workflow" {
1307 workflow_approval_options(risk, locale).to_vec()
1308 } else {
1309 let mut options = approval_options_for(risk, locale).to_vec();
1310 if request.owner.is_some() {
1311 // Must match `ApprovalOption::CHILD_ORDER`: no "Stop this turn".
1312 options.pop();
1313 return options;
1314 }
1315 if request.can_save_allow_rule() {
1316 options.insert(
1317 2,
1318 ApprovalOptionRow {
1319 label: tr(locale, MessageId::ApprovalOptionAllowExactRepo),
1320 key_hint: "p",
1321 dangerous: false,
1322 persistent: true,
1323 },
1324 );
1325 }
1326 options
1327 }
1328 }
1329
1330 fn workflow_option_approve(locale: Locale) -> Cow<'static, str> {
1331 match locale {
1332 Locale::ZhHans => Cow::Borrowed("批准"),
1333 _ => Cow::Borrowed("Approve"),
1334 }
1335 }
1336
1337 fn workflow_option_edit_plan(locale: Locale) -> Cow<'static, str> {
1338 match locale {
1339 Locale::ZhHans => Cow::Borrowed("编辑计划"),
1340 _ => Cow::Borrowed("Edit plan"),
1341 }
1342 }
1343
1344 fn workflow_option_cancel(locale: Locale) -> Cow<'static, str> {
1345 match locale {
1346 Locale::ZhHans => Cow::Borrowed("取消"),
1347 _ => Cow::Borrowed("Cancel"),
1348 }
1349 }
1350
1351 fn option_approve_once(locale: Locale) -> Cow<'static, str> {
1352 tr(locale, MessageId::ApprovalOptionApproveOnce)
1353 }
1354
1355 fn option_approve_always(locale: Locale) -> Cow<'static, str> {
1356 tr(locale, MessageId::ApprovalOptionApproveAlways)
1357 }
1358
1359 fn option_deny(locale: Locale) -> Cow<'static, str> {
1360 tr(locale, MessageId::ApprovalOptionDeny)
1361 }
1362
1363 fn option_abort(locale: Locale) -> Cow<'static, str> {
1364 tr(locale, MessageId::ApprovalOptionAbortTurn)
1365 }
1366
1367 // End exact frozen source counterpart; never included in production.
1368
1368 lines RUST