返回 CodeWhale
approval.rs
1 //! Package Approval: the approval card and the review verdict.
2 //!
3 //! This is what a person reads before letting an agent run a command or change
4 //! files, so it follows a few rules that nothing else in the kit needs:
5 //!
6 //! - **The subject is verbatim.** The command or target is never elided in the
7 //! middle. It wraps; if the card cannot show all of it, the card says how many
8 //! characters and lines are not shown and offers the binding that reveals
9 //! them, and [`ApprovalPaint`] tells the caller so it can refuse a blind
10 //! approval.
11 //! - **What is shown is what will run.** Every control character, escape
12 //! sequence, bidi override, zero-width or otherwise invisible character is
13 //! drawn as a named token (`‹ESC›`, `‹RLO›`, `‹ZWJ›`, `‹LF›`, `‹HT›`), styled
14 //! reversed and bold so it cannot be mistaken for text. Leading and trailing
15 //! spaces are drawn too (`‹SP×3›`). A literal `‹` that could be mistaken for
16 //! the start of a token is itself drawn as a token (`‹U+2039›`), so the
17 //! display decodes to exactly one subject. [`visible_text`] is that encoding.
18 //! - **Nothing is decided by color, and nothing is preselected.** "Outside this
19 //! project" and elevation carry a mark and words. The choices come from the
20 //! caller with their key chords; the card never invents one, and the default
21 //! focus is the first choice that does not grant anything.
22 //! - **Displayed chords are handled chords.** [`ApprovalState`] owns the
23 //! choices, so the keys the card advertises are the keys `handle_key`
24 //! accepts.
25 //!
26 //! The host owns policy and input guards. A key that arrives before the card
27 //! was drawn should not count; [`ApprovalState::unarmed`] and
28 //! [`ApprovalState::arm`] are the hooks for that.
29
30 use std::borrow::Cow;
31
32 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
33 use ratatui::{
34 buffer::Buffer,
35 layout::Rect,
36 style::{Modifier, Style},
37 text::{Line, Span},
38 };
39 use unicode_segmentation::UnicodeSegmentation;
40 use unicode_width::UnicodeWidthChar;
41
42 use crate::{
43 Depth, KeyHint, KeyHints, Paint, Panel, Role, State, StateWords, Theme, glyphs,
44 keys::{self, Platform},
45 text,
46 };
47
48 // ---------------------------------------------------------------------------
49 // Words
50 // ---------------------------------------------------------------------------
51
52 /// A phrase with a singular and a plural form. `{n}` is replaced by the count.
53 #[derive(Clone, Debug, PartialEq, Eq)]
54 pub struct ApprovalCountWords {
55 pub one: Cow<'static, str>,
56 pub many: Cow<'static, str>,
57 }
58
59 impl ApprovalCountWords {
60 #[must_use]
61 pub fn new(one: impl Into<Cow<'static, str>>, many: impl Into<Cow<'static, str>>) -> Self {
62 Self {
63 one: one.into(),
64 many: many.into(),
65 }
66 }
67
68 /// The phrase for `n`.
69 #[must_use]
70 pub fn render(&self, n: usize) -> String {
71 let phrase = if n == 1 { &self.one } else { &self.many };
72 phrase.replace("{n}", &n.to_string())
73 }
74 }
75
76 /// The words of the "not shown" notice, shared by the card and the verdict.
77 #[derive(Clone, Debug, PartialEq, Eq)]
78 pub struct ApprovalClipWords {
79 pub hidden_chars: ApprovalCountWords,
80 pub hidden_lines: ApprovalCountWords,
81 pub hidden_preview: ApprovalCountWords,
82 pub hidden_details: ApprovalCountWords,
83 pub hidden_choices: ApprovalCountWords,
84 /// Verb beside the reveal binding: `o show all`.
85 pub reveal: Cow<'static, str>,
86 /// Shown for a subject with no characters.
87 pub empty: Cow<'static, str>,
88 }
89
90 impl Default for ApprovalClipWords {
91 fn default() -> Self {
92 Self {
93 hidden_chars: ApprovalCountWords::new(
94 "{n} character not shown",
95 "{n} characters not shown",
96 ),
97 hidden_lines: ApprovalCountWords::new("{n} line", "{n} lines"),
98 hidden_preview: ApprovalCountWords::new(
99 "{n} preview line not shown",
100 "{n} preview lines not shown",
101 ),
102 hidden_details: ApprovalCountWords::new(
103 "{n} detail line not shown",
104 "{n} detail lines not shown",
105 ),
106 hidden_choices: ApprovalCountWords::new(
107 "{n} choice not shown",
108 "{n} choices not shown",
109 ),
110 reveal: "show all".into(),
111 empty: "(empty)".into(),
112 }
113 }
114 }
115
116 /// Every word the approval card shows. The kit owns no copy beyond this
117 /// English default: a host fills one per locale and passes it in.
118 #[derive(Clone, Debug, PartialEq, Eq)]
119 pub struct ApprovalWords {
120 pub needs_you: Cow<'static, str>,
121 pub command: Cow<'static, str>,
122 pub file_change: Cow<'static, str>,
123 pub network: Cow<'static, str>,
124 pub tool_call: Cow<'static, str>,
125 pub elevation: Cow<'static, str>,
126 /// `In /path/to/dir`
127 pub in_dir: Cow<'static, str>,
128 pub inside: Cow<'static, str>,
129 pub outside: Cow<'static, str>,
130 pub unchecked: Cow<'static, str>,
131 pub elevated: Cow<'static, str>,
132 pub requested_by: Cow<'static, str>,
133 pub sub_agent: Cow<'static, str>,
134 pub risk: Cow<'static, str>,
135 pub preview: Cow<'static, str>,
136 pub non_ascii: ApprovalCountWords,
137 /// Key-hint verbs for the rail under the card.
138 pub move_focus: Cow<'static, str>,
139 pub choose_focused: Cow<'static, str>,
140 pub cancel: Cow<'static, str>,
141 pub clip: ApprovalClipWords,
142 }
143
144 impl Default for ApprovalWords {
145 fn default() -> Self {
146 Self {
147 needs_you: Cow::Borrowed(StateWords::english(State::NeedsYou)),
148 command: "Run a command".into(),
149 file_change: "Change files".into(),
150 network: "Use the network".into(),
151 tool_call: "Call a tool".into(),
152 elevation: "Raise access".into(),
153 in_dir: "In".into(),
154 inside: "Inside this project".into(),
155 outside: "Outside this project".into(),
156 unchecked: "Not checked against the project".into(),
157 elevated: "Runs with elevated access".into(),
158 requested_by: "Requested by".into(),
159 sub_agent: "sub-agent".into(),
160 risk: "Risk".into(),
161 preview: "Preview".into(),
162 non_ascii: ApprovalCountWords::new(
163 "Contains {n} non-ASCII character: check it",
164 "Contains {n} non-ASCII characters: check them",
165 ),
166 move_focus: "move".into(),
167 choose_focused: "choose".into(),
168 cancel: "cancel".into(),
169 clip: ApprovalClipWords::default(),
170 }
171 }
172 }
173
174 impl ApprovalWords {
175 fn kind(&self, kind: ApprovalKind) -> &str {
176 match kind {
177 ApprovalKind::Command => &self.command,
178 ApprovalKind::FileChange => &self.file_change,
179 ApprovalKind::Network => &self.network,
180 ApprovalKind::ToolCall => &self.tool_call,
181 ApprovalKind::Elevation => &self.elevation,
182 }
183 }
184 }
185
186 // ---------------------------------------------------------------------------
187 // The subject
188 // ---------------------------------------------------------------------------
189
190 /// What the agent wants to do.
191 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
192 pub enum ApprovalKind {
193 /// Run a shell command.
194 Command,
195 /// Write or patch files.
196 FileChange,
197 /// Reach the network.
198 Network,
199 /// Call a tool or connected app.
200 ToolCall,
201 /// Run with more access than the sandbox gives.
202 Elevation,
203 }
204
205 /// Where the action lands relative to the project. Never assumed: the default
206 /// is [`ApprovalScope::Unchecked`].
207 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
208 pub enum ApprovalScope {
209 Inside,
210 Outside,
211 #[default]
212 Unchecked,
213 }
214
215 /// The agent that is asking.
216 #[derive(Clone, Debug, PartialEq, Eq)]
217 pub struct ApprovalAgent<'a> {
218 pub name: Cow<'a, str>,
219 pub sub_agent: bool,
220 }
221
222 /// Plain data the caller fills. Every string may come from a model or a file:
223 /// the card draws all of it through [`visible_text`].
224 #[derive(Clone, Debug, PartialEq, Eq)]
225 pub struct ApprovalSubject<'a> {
226 pub kind: ApprovalKind,
227 /// The exact command, path, URL or tool call.
228 pub target: Cow<'a, str>,
229 pub cwd: Option<Cow<'a, str>>,
230 pub scope: ApprovalScope,
231 pub agent: Option<ApprovalAgent<'a>>,
232 /// Risk notes the caller supplies, one per row.
233 pub risk_notes: Vec<Cow<'a, str>>,
234 /// A patch or file preview, one line per entry.
235 pub preview: Vec<Cow<'a, str>>,
236 }
237
238 impl<'a> ApprovalSubject<'a> {
239 #[must_use]
240 pub fn new(kind: ApprovalKind, target: impl Into<Cow<'a, str>>) -> Self {
241 Self {
242 kind,
243 target: target.into(),
244 cwd: None,
245 scope: ApprovalScope::Unchecked,
246 agent: None,
247 risk_notes: Vec::new(),
248 preview: Vec::new(),
249 }
250 }
251
252 #[must_use]
253 pub fn cwd(mut self, cwd: impl Into<Cow<'a, str>>) -> Self {
254 self.cwd = Some(cwd.into());
255 self
256 }
257
258 #[must_use]
259 pub fn scope(mut self, scope: ApprovalScope) -> Self {
260 self.scope = scope;
261 self
262 }
263
264 #[must_use]
265 pub fn agent(mut self, name: impl Into<Cow<'a, str>>, sub_agent: bool) -> Self {
266 self.agent = Some(ApprovalAgent {
267 name: name.into(),
268 sub_agent,
269 });
270 self
271 }
272
273 #[must_use]
274 pub fn risk_note(mut self, note: impl Into<Cow<'a, str>>) -> Self {
275 self.risk_notes.push(note.into());
276 self
277 }
278
279 #[must_use]
280 pub fn preview_line(mut self, line: impl Into<Cow<'a, str>>) -> Self {
281 self.preview.push(line.into());
282 self
283 }
284 }
285
286 // ---------------------------------------------------------------------------
287 // Visible text: the one encoding of "what will run"
288 // ---------------------------------------------------------------------------
289
290 /// How tokens are spelled. Unicode terminals get `‹ESC›`; ASCII-safe ones
291 /// get `<ESC>` (the glyph charter's own fallback for the guillemets).
292 #[derive(Clone, Copy)]
293 struct Marks {
294 open: &'static str,
295 close: &'static str,
296 times: &'static str,
297 }
298
299 impl Marks {
300 fn new(ascii: bool) -> Self {
301 Self {
302 open: glyphs::pick("‹", ascii),
303 close: glyphs::pick("›", ascii),
304 times: if ascii { "x" } else { "×" },
305 }
306 }
307
308 fn token(self, name: &str) -> String {
309 format!("{}{name}{}", self.open, self.close)
310 }
311
312 fn spaces(self, n: usize) -> String {
313 if n == 1 {
314 self.token("SP")
315 } else {
316 self.token(&format!("SP{}{n}", self.times))
317 }
318 }
319
320 /// Whether `rest` (the source after an opener) would read as the rest of
321 /// a token: name characters, then the closer. Such an opener is itself
322 /// drawn as a token, which keeps the encoding one-to-one.
323 fn looks_like_token(self, rest: &str) -> bool {
324 let mut names = 0;
325 for c in rest.chars() {
326 if c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '+' | 'x' | '×') {
327 names += 1;
328 } else {
329 return names > 0 && self.close.starts_with(c);
330 }
331 }
332 false
333 }
334 }
335
336 /// A character that is drawn as a token rather than as itself: control
337 /// characters, bidi controls, zero-width and invisible characters, and every
338 /// space except the ordinary one.
339 fn is_flagged(c: char) -> bool {
340 c.is_control()
341 || matches!(c,
342 '\u{00A0}' | '\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{115F}' | '\u{1160}'
343 | '\u{1680}' | '\u{17B4}' | '\u{17B5}' | '\u{180B}'..='\u{180F}'
344 | '\u{2000}'..='\u{200F}' | '\u{2028}'..='\u{202F}' | '\u{205F}'..='\u{206F}'
345 | '\u{2800}' | '\u{3000}' | '\u{3164}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}'
346 | '\u{FFA0}' | '\u{FFF9}'..='\u{FFFC}' | '\u{1D173}'..='\u{1D17A}'
347 | '\u{E0000}'..='\u{E01EF}')
348 }
349
350 fn char_name(c: char) -> String {
351 let name = match c {
352 '\0' => "NUL",
353 '\u{7}' => "BEL",
354 '\u{8}' => "BS",
355 '\t' => "HT",
356 '\n' => "LF",
357 '\u{B}' => "VT",
358 '\u{C}' => "FF",
359 '\r' => "CR",
360 '\u{1B}' => "ESC",
361 '\u{7F}' => "DEL",
362 '\u{A0}' => "NBSP",
363 '\u{AD}' => "SHY",
364 '\u{61C}' => "ALM",
365 '\u{200B}' => "ZWSP",
366 '\u{200C}' => "ZWNJ",
367 '\u{200D}' => "ZWJ",
368 '\u{200E}' => "LRM",
369 '\u{200F}' => "RLM",
370 '\u{202A}' => "LRE",
371 '\u{202B}' => "RLE",
372 '\u{202C}' => "PDF",
373 '\u{202D}' => "LRO",
374 '\u{202E}' => "RLO",
375 '\u{2060}' => "WJ",
376 '\u{2066}' => "LRI",
377 '\u{2067}' => "RLI",
378 '\u{2068}' => "FSI",
379 '\u{2069}' => "PDI",
380 '\u{FEFF}' => "BOM",
381 _ => return format!("U+{:04X}", c as u32),
382 };
383 name.to_owned()
384 }
385
386 /// One drawn piece of text: a grapheme, or a token standing for characters.
387 #[derive(Clone)]
388 struct Unit<'a> {
389 text: Cow<'a, str>,
390 width: usize,
391 /// Source characters this unit stands for (0 for the card's own words).
392 chars: usize,
393 style: Style,
394 /// An ordinary space: a place where a row may break.
395 space: bool,
396 }
397
398 struct Looks {
399 plain: Style,
400 token: Style,
401 }
402
403 /// Visit `src` as the units it is drawn with. Returns how many ordinary
404 /// (non-flagged) non-ASCII characters it holds.
405 fn escape<'a>(
406 src: &'a str,
407 ascii: bool,
408 edges: bool,
409 looks: &Looks,
410 mut visit: impl FnMut(Unit<'a>),
411 ) -> usize {
412 let marks = Marks::new(ascii);
413 let token_unit = |name: String, chars: usize| Unit {
414 width: text::width(&name),
415 text: Cow::Owned(name),
416 chars,
417 style: looks.token,
418 space: false,
419 };
420 // Leading and trailing spaces are drawn for anything that is run or
421 // named; prose and previews (indented code, diff context) keep theirs.
422 let lead = if edges {
423 src.len() - src.trim_start_matches(' ').len()
424 } else {
425 0
426 };
427 let trail = if !edges || lead == src.len() {
428 0
429 } else {
430 src.len() - src.trim_end_matches(' ').len()
431 };
432 let mid = &src[lead..src.len() - trail];
433 let mut non_ascii = 0;
434 if lead > 0 {
435 visit(token_unit(marks.spaces(lead), lead));
436 }
437 for (at, g) in mid.grapheme_indices(true) {
438 if !g.chars().any(is_flagged) && text::width(g) > 0 {
439 if g == marks.open && marks.looks_like_token(&mid[at + g.len()..]) {
440 let opener = marks.open.chars().next().unwrap_or('<');
441 visit(token_unit(marks.token(&char_name(opener)), 1));
442 continue;
443 }
444 non_ascii += g.chars().filter(|c| !c.is_ascii()).count();
445 visit(Unit {
446 text: Cow::Borrowed(g),
447 width: text::width(g),
448 chars: g.chars().count(),
449 style: looks.plain,
450 space: g == " ",
451 });
452 continue;
453 }
454 // A cluster with something invisible in it: draw each invisible
455 // character as a token and keep the visible runs between them.
456 let mut run: Option<usize> = None;
457 let mut flush = |end: usize, run: &mut Option<usize>, visit: &mut dyn FnMut(Unit<'a>)| {
458 if let Some(start) = run.take() {
459 let piece = &g[start..end];
460 if text::width(piece) > 0 {
461 non_ascii += piece.chars().filter(|c| !c.is_ascii()).count();
462 visit(Unit {
463 text: Cow::Borrowed(piece),
464 width: text::width(piece),
465 chars: piece.chars().count(),
466 style: looks.plain,
467 space: false,
468 });
469 } else {
470 for c in piece.chars() {
471 visit(token_unit(marks.token(&char_name(c)), 1));
472 }
473 }
474 }
475 };
476 for (off, c) in g.char_indices() {
477 if is_flagged(c) {
478 flush(off, &mut run, &mut visit);
479 visit(token_unit(marks.token(&char_name(c)), 1));
480 } else if run.is_none() {
481 run = Some(off);
482 }
483 }
484 flush(g.len(), &mut run, &mut visit);
485 }
486 if trail > 0 {
487 visit(token_unit(marks.spaces(trail), trail));
488 }
489 non_ascii
490 }
491
492 /// `src` as the card draws it: every invisible, control or direction-changing
493 /// character as a named token, leading and trailing spaces as `SP` tokens.
494 /// This is the text a test or a host can compare with what was painted.
495 #[must_use]
496 pub fn visible_text(src: &str, ascii: bool) -> String {
497 let looks = Looks {
498 plain: Style::default(),
499 token: Style::default(),
500 };
501 let mut out = String::new();
502 escape(src, ascii, true, &looks, |u| out.push_str(&u.text));
503 out
504 }
505
506 // ---------------------------------------------------------------------------
507 // Rows and wrapping
508 // ---------------------------------------------------------------------------
509
510 /// One painted row, with the source characters (or choices) it carries.
511 #[derive(Clone)]
512 struct Row {
513 spans: Vec<Span<'static>>,
514 units: usize,
515 }
516
517 fn spans_of(units: &[Unit<'_>]) -> Vec<Span<'static>> {
518 let mut spans = Vec::new();
519 let mut buf = String::new();
520 let mut style: Option<Style> = None;
521 for u in units {
522 if style != Some(u.style) {
523 if let Some(s) = style {
524 spans.push(Span::styled(std::mem::take(&mut buf), s));
525 }
526 style = Some(u.style);
527 }
528 buf.push_str(&u.text);
529 }
530 if let Some(s) = style {
531 spans.push(Span::styled(buf, s));
532 }
533 spans
534 }
535
536 #[derive(Default)]
537 struct Wrapped {
538 rows: Vec<Row>,
539 total_rows: usize,
540 total_chars: usize,
541 }
542
543 /// Greedy wrapping that never drops or rewrites a character: a row breaks
544 /// after an ordinary space when that leaves the row at least half full, and
545 /// otherwise at the cell where it runs out. The rows, read in order, are the
546 /// text.
547 struct Wrapper<'a> {
548 width: usize,
549 cap: usize,
550 cur: Vec<Unit<'a>>,
551 cur_w: usize,
552 last_break: Option<usize>,
553 any: bool,
554 out: Wrapped,
555 }
556
557 impl<'a> Wrapper<'a> {
558 fn new(width: usize, cap: usize) -> Self {
559 Self {
560 width,
561 cap,
562 cur: Vec::new(),
563 cur_w: 0,
564 last_break: None,
565 any: false,
566 out: Wrapped::default(),
567 }
568 }
569
570 fn emit(&mut self, spans: Vec<Span<'static>>, units: usize) {
571 self.out.total_rows += 1;
572 if self.out.rows.len() < self.cap {
573 self.out.rows.push(Row { spans, units });
574 }
575 }
576
577 fn flush(&mut self) {
578 if self.cur.is_empty() {
579 return;
580 }
581 let units = self.cur.iter().map(|u| u.chars).sum();
582 let spans = spans_of(&self.cur);
583 self.emit(spans, units);
584 self.cur.clear();
585 self.cur_w = 0;
586 self.last_break = None;
587 }
588
589 fn push(&mut self, u: Unit<'a>) {
590 self.any = true;
591 self.out.total_chars += u.chars;
592 if self.width == 0 {
593 return;
594 }
595 if u.width > self.width {
596 self.flush();
597 self.overwide(&u);
598 return;
599 }
600 if self.cur_w + u.width > self.width {
601 let split = match self.last_break {
602 Some(b)
603 if b < self.cur.len()
604 && self.cur[..b].iter().map(|u| u.width).sum::<usize>() * 2
605 >= self.width =>
606 {
607 b
608 }
609 _ => self.cur.len(),
610 };
611 let carry = self.cur.split_off(split);
612 self.flush();
613 self.cur_w = carry.iter().map(|u| u.width).sum();
614 self.cur = carry;
615 if self.cur_w + u.width > self.width {
616 self.flush();
617 }
618 }
619 self.cur_w += u.width;
620 let space = u.space;
621 self.cur.push(u);
622 if space {
623 self.last_break = Some(self.cur.len());
624 }
625 }
626
627 /// A unit wider than a whole row (a token at a tiny width): split it
628 /// across rows by character. A single character wider than the row has
629 /// nowhere to go and counts as not shown.
630 fn overwide(&mut self, u: &Unit<'_>) {
631 let mut piece = String::new();
632 let mut used = 0;
633 for c in u.text.chars() {
634 let w = c.width().unwrap_or(0);
635 if w > self.width {
636 if !piece.is_empty() {
637 self.emit(vec![Span::styled(std::mem::take(&mut piece), u.style)], 0);
638 used = 0;
639 }
640 self.emit(Vec::new(), 0);
641 continue;
642 }
643 if used + w > self.width {
644 self.emit(vec![Span::styled(std::mem::take(&mut piece), u.style)], 0);
645 used = 0;
646 }
647 piece.push(c);
648 used += w;
649 }
650 if !piece.is_empty() {
651 // The characters count as shown only once the last piece is.
652 self.emit(vec![Span::styled(piece, u.style)], u.chars);
653 }
654 }
655
656 fn finish(mut self) -> Wrapped {
657 self.flush();
658 if self.out.total_rows == 0 {
659 // A blank line still takes a row.
660 self.emit(Vec::new(), 0);
661 }
662 self.out
663 }
664
665 /// Push the card's own words (no source characters).
666 fn words(&mut self, words: &str, style: Style) {
667 for g in text::display_safe(words).graphemes(true) {
668 self.push(Unit {
669 text: Cow::Owned(g.to_owned()),
670 width: text::width(g),
671 chars: 0,
672 style,
673 space: g == " ",
674 });
675 }
676 }
677
678 /// Push caller text through [`escape`]; returns its non-ASCII count.
679 fn subject(&mut self, src: &'a str, ascii: bool, looks: &Looks) -> usize {
680 escape(src, ascii, true, looks, |u| self.push(u))
681 }
682
683 /// Like [`Wrapper::subject`] for prose and previews: control characters
684 /// and invisible characters are drawn, edge spaces are left alone.
685 fn prose(&mut self, src: &'a str, ascii: bool, looks: &Looks) -> usize {
686 escape(src, ascii, false, looks, |u| self.push(u))
687 }
688 }
689
690 /// Rows of one part of the card, with what did not fit.
691 struct Section {
692 rows: Vec<Row>,
693 total_rows: usize,
694 total_chars: usize,
695 cap: usize,
696 }
697
698 impl Section {
699 fn new(cap: usize) -> Self {
700 Self {
701 rows: Vec::new(),
702 total_rows: 0,
703 total_chars: 0,
704 cap,
705 }
706 }
707
708 fn absorb(&mut self, wrapped: Wrapped) {
709 self.total_rows += wrapped.total_rows;
710 self.total_chars += wrapped.total_chars;
711 for row in wrapped.rows {
712 if self.rows.len() < self.cap {
713 self.rows.push(row);
714 }
715 }
716 }
717
718 /// One logical line, wrapped into `width`, with a hanging gutter.
719 fn line<'a>(
720 &mut self,
721 width: usize,
722 gutter: Option<(&str, Style, &str, Style)>,
723 build: impl FnOnce(&mut Wrapper<'a>),
724 ) {
725 let gutter_w = gutter.map_or(0, |g| text::width(g.0));
726 let mut wrapper = Wrapper::new(width.saturating_sub(gutter_w), self.cap);
727 build(&mut wrapper);
728 let mut wrapped = wrapper.finish();
729 if let Some((first, first_style, rest, rest_style)) = gutter {
730 for (i, row) in wrapped.rows.iter_mut().enumerate() {
731 let (g, s) = if i == 0 {
732 (first, first_style)
733 } else {
734 (rest, rest_style)
735 };
736 row.spans.insert(0, Span::styled(g.to_owned(), s));
737 }
738 }
739 self.absorb(wrapped);
740 }
741
742 fn hidden(&self, shown: usize) -> ApprovalHidden {
743 let shown = shown.min(self.rows.len());
744 let shown_chars: usize = self.rows[..shown].iter().map(|r| r.units).sum();
745 ApprovalHidden {
746 chars: self.total_chars.saturating_sub(shown_chars),
747 lines: self.total_rows.saturating_sub(shown),
748 }
749 }
750 }
751
752 /// Pack unbreakable items into rows, `sep` between them on a row; an item
753 /// wider than a row wraps. Row units count the items that end in the row: an
754 /// item counts as shown only once its last row is, so a choice cut off after
755 /// its key is reported as cut.
756 fn pack(
757 items: Vec<Vec<Unit<'static>>>,
758 sep: &[Unit<'static>],
759 width: usize,
760 cap: usize,
761 ) -> Section {
762 let sep_w: usize = sep.iter().map(|u| u.width).sum();
763 let mut sec = Section::new(cap);
764 let mut cur: Vec<Unit<'static>> = Vec::new();
765 let mut cur_items = 0;
766 let mut used = 0;
767 let flush = |sec: &mut Section, cur: &mut Vec<Unit<'static>>, n: &mut usize| {
768 if !cur.is_empty() {
769 sec.total_rows += 1;
770 if sec.rows.len() < sec.cap {
771 sec.rows.push(Row {
772 spans: spans_of(cur),
773 units: *n,
774 });
775 }
776 cur.clear();
777 *n = 0;
778 }
779 };
780 for item in items {
781 sec.total_chars += 1;
782 let w: usize = item.iter().map(|u| u.width).sum();
783 if w > width {
784 flush(&mut sec, &mut cur, &mut cur_items);
785 used = 0;
786 let mut wrapper = Wrapper::new(width, cap);
787 for u in item {
788 wrapper.push(u);
789 }
790 let mut wrapped = wrapper.finish();
791 // The last row only counts if it was kept: rows past the cap are
792 // dropped, and an item whose tail is dropped is not shown.
793 if wrapped.rows.len() == wrapped.total_rows
794 && let Some(last) = wrapped.rows.last_mut()
795 {
796 last.units = 1;
797 }
798 wrapped.total_chars = 0;
799 sec.absorb(wrapped);
800 continue;
801 }
802 if !cur.is_empty() && used + sep_w + w > width {
803 flush(&mut sec, &mut cur, &mut cur_items);
804 used = 0;
805 }
806 if !cur.is_empty() {
807 cur.extend(sep.iter().cloned());
808 used += sep_w;
809 }
810 used += w;
811 cur.extend(item);
812 cur_items += 1;
813 }
814 flush(&mut sec, &mut cur, &mut cur_items);
815 sec
816 }
817
818 fn trusted(text: &str, style: Style) -> Vec<Unit<'static>> {
819 text::display_safe(text)
820 .graphemes(true)
821 .map(|g| Unit {
822 text: Cow::Owned(g.to_owned()),
823 width: text::width(g),
824 chars: 0,
825 style,
826 space: g == " ",
827 })
828 .collect()
829 }
830
831 // ---------------------------------------------------------------------------
832 // What a paint reports
833 // ---------------------------------------------------------------------------
834
835 /// What one part of a card did not show.
836 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
837 pub struct ApprovalHidden {
838 /// Source characters not shown.
839 pub chars: usize,
840 /// Rows not shown.
841 pub lines: usize,
842 }
843
844 impl ApprovalHidden {
845 #[must_use]
846 pub const fn any(self) -> bool {
847 self.chars > 0 || self.lines > 0
848 }
849
850 const fn plus(self, other: Self) -> Self {
851 Self {
852 chars: self.chars + other.chars,
853 lines: self.lines + other.lines,
854 }
855 }
856 }
857
858 /// What a paint showed and did not show. The caller reads [`clipped`] to
859 /// refuse a blind approval.
860 ///
861 /// [`clipped`]: ApprovalPaint::clipped
862 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
863 pub struct ApprovalPaint {
864 /// The exact command or target.
865 pub subject: ApprovalHidden,
866 /// The preview.
867 pub preview: ApprovalHidden,
868 /// Agent, location, elevation, risk notes and the non-ASCII note.
869 pub details: ApprovalHidden,
870 /// Choices that did not fit and so cannot be seen.
871 pub choices: usize,
872 /// Non-ASCII characters in the subject, location and agent name (the
873 /// card says so when there are any; this is how many).
874 pub non_ascii: usize,
875 }
876
877 impl ApprovalPaint {
878 /// Whether anything the person should read before deciding was not shown.
879 #[must_use]
880 pub const fn clipped(&self) -> bool {
881 self.subject.any() || self.preview.any() || self.details.any() || self.choices > 0
882 }
883 }
884
885 // ---------------------------------------------------------------------------
886 // Choices and keys
887 // ---------------------------------------------------------------------------
888
889 /// A caller-defined name for a choice. The kit never interprets it.
890 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
891 pub struct ChoiceId(pub u32);
892
893 /// What choosing does to the action. The card uses it for one thing: the
894 /// default focus is never a choice that grants.
895 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
896 pub enum ApprovalEffect {
897 /// The action may proceed.
898 Grants,
899 /// The action does not proceed.
900 Refuses,
901 /// Neither: "deny and say why" (it refuses, then asks), "open details".
902 Other,
903 }
904
905 /// A key and its modifiers, matched exactly: `y` is not `Y`, and `Ctrl+Y` is
906 /// neither.
907 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
908 pub struct ApprovalKey {
909 pub code: KeyCode,
910 pub modifiers: KeyModifiers,
911 }
912
913 impl ApprovalKey {
914 #[must_use]
915 pub const fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
916 Self { code, modifiers }
917 }
918
919 /// A character with no modifier.
920 #[must_use]
921 pub const fn char(c: char) -> Self {
922 Self::new(KeyCode::Char(c), KeyModifiers::NONE)
923 }
924
925 /// `Ctrl` plus a character.
926 #[must_use]
927 pub const fn ctrl(c: char) -> Self {
928 Self::new(KeyCode::Char(c), KeyModifiers::CONTROL)
929 }
930
931 /// The label the card shows, spelled as every other key in the kit.
932 #[must_use]
933 pub fn label(&self, platform: Platform) -> String {
934 keys::chord_label(&KeyEvent::new(self.code, self.modifiers), platform)
935 }
936
937 fn matches(&self, key: &KeyEvent) -> bool {
938 key.code == self.code && key.modifiers == self.modifiers
939 }
940
941 /// Keys that move focus, confirm or cancel are the card's own.
942 const fn reserved(&self) -> bool {
943 matches!(
944 self.code,
945 KeyCode::Enter
946 | KeyCode::Esc
947 | KeyCode::Tab
948 | KeyCode::BackTab
949 | KeyCode::Left
950 | KeyCode::Right
951 | KeyCode::Up
952 | KeyCode::Down
953 )
954 }
955 }
956
957 /// One thing the person can choose.
958 #[derive(Clone, Debug, PartialEq, Eq)]
959 pub struct ApprovalChoice {
960 pub id: ChoiceId,
961 pub label: Cow<'static, str>,
962 pub effect: ApprovalEffect,
963 /// Chords that choose directly. The first is the one shown. A chord an
964 /// earlier choice already has, or one the card owns, is dropped by
965 /// [`ApprovalState::new`].
966 pub keys: Vec<ApprovalKey>,
967 }
968
969 impl ApprovalChoice {
970 #[must_use]
971 pub fn new(id: ChoiceId, label: impl Into<Cow<'static, str>>, effect: ApprovalEffect) -> Self {
972 Self {
973 id,
974 label: label.into(),
975 effect,
976 keys: Vec::new(),
977 }
978 }
979
980 #[must_use]
981 pub fn key(mut self, key: ApprovalKey) -> Self {
982 self.keys.push(key);
983 self
984 }
985
986 /// A plain character chord.
987 #[must_use]
988 pub fn char_key(self, c: char) -> Self {
989 self.key(ApprovalKey::char(c))
990 }
991 }
992
993 /// What a key did to an [`ApprovalState`]: the message a host reacts to.
994 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
995 pub enum ApprovalOutcome {
996 /// The key means nothing to the card; the host may use it.
997 Ignored,
998 /// Focus moved; repaint.
999 Moved,
1000 /// A choice was made: Enter on the focused choice, or its chord.
1001 Chose(ChoiceId),
1002 /// The reveal chord: show the whole subject.
1003 Reveal,
1004 /// Esc. The caller decides whether that denies.
1005 Cancelled,
1006 }
1007
1008 /// The choices, which one has focus, and the keys. The card paints from this,
1009 /// so the chords it shows are the chords [`ApprovalState::handle_key`] takes.
1010 #[derive(Clone, Debug, PartialEq, Eq)]
1011 pub struct ApprovalState {
1012 choices: Vec<ApprovalChoice>,
1013 focus: Option<usize>,
1014 reveal: Option<ApprovalKey>,
1015 armed: bool,
1016 }
1017
1018 impl ApprovalState {
1019 /// Focus starts on the first choice that refuses, else the first that is
1020 /// neither, else nowhere: a card of only grants has no default, and Enter
1021 /// then chooses nothing.
1022 #[must_use]
1023 pub fn new(choices: Vec<ApprovalChoice>) -> Self {
1024 let mut state = Self {
1025 choices,
1026 focus: None,
1027 reveal: None,
1028 armed: true,
1029 };
1030 state.sanitize();
1031 state.focus = [ApprovalEffect::Refuses, ApprovalEffect::Other]
1032 .into_iter()
1033 .find_map(|effect| state.choices.iter().position(|c| c.effect == effect));
1034 state
1035 }
1036
1037 /// Bind the key that reveals the whole subject.
1038 #[must_use]
1039 pub fn reveal(mut self, key: ApprovalKey) -> Self {
1040 self.reveal = (!key.reserved()).then_some(key);
1041 self.sanitize();
1042 self
1043 }
1044
1045 /// Ignore every key until [`ApprovalState::arm`]: the host arms the state
1046 /// after the card was drawn, so keys typed ahead cannot answer it.
1047 #[must_use]
1048 pub fn unarmed(mut self) -> Self {
1049 self.armed = false;
1050 self
1051 }
1052
1053 pub fn arm(&mut self) {
1054 self.armed = true;
1055 }
1056
1057 #[must_use]
1058 pub fn choices(&self) -> &[ApprovalChoice] {
1059 &self.choices
1060 }
1061
1062 #[must_use]
1063 pub fn reveal_key(&self) -> Option<ApprovalKey> {
1064 self.reveal
1065 }
1066
1067 /// The index of the focused choice.
1068 #[must_use]
1069 pub fn focus(&self) -> Option<usize> {
1070 self.focus
1071 }
1072
1073 #[must_use]
1074 pub fn focused(&self) -> Option<ChoiceId> {
1075 self.focus.map(|i| self.choices[i].id)
1076 }
1077
1078 /// Whether choosing `id` lets the action proceed. A host that refuses a
1079 /// blind approval asks this of a [`ApprovalOutcome::Chose`] while the last
1080 /// paint [`ApprovalPaint::clipped`].
1081 #[must_use]
1082 pub fn grants(&self, id: ChoiceId) -> bool {
1083 self.choices
1084 .iter()
1085 .any(|c| c.id == id && c.effect == ApprovalEffect::Grants)
1086 }
1087
1088 /// Drop chords the card owns (navigation, Enter, Esc, the reveal key) and
1089 /// chords an earlier choice already has, so the card never advertises a
1090 /// key that does something else.
1091 fn sanitize(&mut self) {
1092 let reveal = self.reveal;
1093 let mut taken: Vec<ApprovalKey> = Vec::new();
1094 for choice in &mut self.choices {
1095 choice
1096 .keys
1097 .retain(|k| !k.reserved() && Some(*k) != reveal && !taken.contains(k));
1098 taken.extend(choice.keys.iter().copied());
1099 }
1100 }
1101
1102 fn step(&mut self, forward: bool) -> ApprovalOutcome {
1103 let len = self.choices.len();
1104 if len == 0 {
1105 return ApprovalOutcome::Ignored;
1106 }
1107 self.focus = Some(match (self.focus, forward) {
1108 (None, true) => 0,
1109 (None, false) => len - 1,
1110 (Some(i), true) => (i + 1) % len,
1111 (Some(i), false) => (i + len - 1) % len,
1112 });
1113 ApprovalOutcome::Moved
1114 }
1115
1116 /// Apply a key press. `Enter` chooses the focused choice and nothing
1117 /// else; a choice's chord chooses it directly; arrows and `Tab` move
1118 /// focus; `Esc` is [`ApprovalOutcome::Cancelled`]. Releases, held-key
1119 /// repeats (for everything but moving), modified Enter/Esc/arrows and
1120 /// every other key are [`ApprovalOutcome::Ignored`].
1121 pub fn handle_key(&mut self, key: KeyEvent) -> ApprovalOutcome {
1122 if key.kind == KeyEventKind::Release || !self.armed {
1123 return ApprovalOutcome::Ignored;
1124 }
1125 let repeat = key.kind == KeyEventKind::Repeat;
1126 let plain = key.modifiers.is_empty();
1127 match key.code {
1128 KeyCode::Right | KeyCode::Down | KeyCode::Tab if plain => return self.step(true),
1129 KeyCode::Left | KeyCode::Up if plain => return self.step(false),
1130 KeyCode::BackTab
1131 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
1132 {
1133 return self.step(false);
1134 }
1135 _ => {}
1136 }
1137 // A held key must never answer the card.
1138 if repeat {
1139 return ApprovalOutcome::Ignored;
1140 }
1141 match key.code {
1142 KeyCode::Esc if plain => return ApprovalOutcome::Cancelled,
1143 KeyCode::Enter if plain => {
1144 return self
1145 .focused()
1146 .map_or(ApprovalOutcome::Ignored, ApprovalOutcome::Chose);
1147 }
1148 _ => {}
1149 }
1150 if self.reveal.is_some_and(|k| k.matches(&key)) {
1151 return ApprovalOutcome::Reveal;
1152 }
1153 self.choices
1154 .iter()
1155 .find(|c| c.keys.iter().any(|k| k.matches(&key)))
1156 .map_or(ApprovalOutcome::Ignored, |c| ApprovalOutcome::Chose(c.id))
1157 }
1158 }
1159
1160 // ---------------------------------------------------------------------------
1161 // The card
1162 // ---------------------------------------------------------------------------
1163
1164 struct Styles {
1165 plain: Style,
1166 strong: Style,
1167 muted: Style,
1168 hint: Style,
1169 border: Style,
1170 attention: Style,
1171 danger: Style,
1172 primary: Style,
1173 token: Style,
1174 }
1175
1176 impl Styles {
1177 fn new(theme: &Theme) -> Self {
1178 let bold = Modifier::BOLD;
1179 Self {
1180 plain: theme.fg(Role::Foreground),
1181 strong: theme.fg(Role::Foreground).add_modifier(bold),
1182 muted: theme.fg(Role::Muted),
1183 hint: theme.fg(Role::Hint),
1184 border: theme.fg(Role::Border),
1185 attention: theme.fg(Role::Attention).add_modifier(bold),
1186 danger: theme.fg(Role::Danger).add_modifier(bold),
1187 primary: theme.fg(Role::Primary).add_modifier(bold),
1188 // Reversed and bold: a token is told from text by shape and
1189 // video, not hue, so it reads at 16 colors and under NO_COLOR.
1190 token: theme
1191 .fg(Role::Attention)
1192 .add_modifier(bold | Modifier::REVERSED),
1193 }
1194 }
1195
1196 fn looks(&self, plain: Style) -> Looks {
1197 Looks {
1198 plain,
1199 token: self.token,
1200 }
1201 }
1202 }
1203
1204 const MAX_ROWS: usize = 2048;
1205
1206 /// Between the parts of a line: ` · `, or ` - ` where marks are ASCII-safe
1207 /// (the ASCII form of `·` is `.`, which reads as punctuation).
1208 const fn dot(ascii: bool) -> &'static str {
1209 if ascii { " - " } else { " · " }
1210 }
1211
1212 /// The "not shown" notice for `report`, wrapped into `width`, and whether it
1213 /// is the whole notice. Given only `max_rows` rows it says less rather than run
1214 /// off the end: first the later counts go, then the line count, then the
1215 /// reveal key; the first count never does. `reveal` is the label of the key
1216 /// that shows everything, when the host has one.
1217 fn clip_notice(
1218 clip: &ApprovalClipWords,
1219 report: &ApprovalPaint,
1220 reveal: Option<String>,
1221 width: usize,
1222 max_rows: usize,
1223 theme: &Theme,
1224 st: &Styles,
1225 ) -> (Section, bool) {
1226 let mut first = None;
1227 let mut parts = Vec::new();
1228 if report.subject.any() {
1229 // `760 characters not shown (11 lines)`
1230 let chars = clip.hidden_chars.render(report.subject.chars);
1231 let mut part = chars.clone();
1232 if report.subject.lines > 0 {
1233 part.push_str(&format!(
1234 " ({})",
1235 clip.hidden_lines.render(report.subject.lines)
1236 ));
1237 }
1238 first = Some(chars);
1239 parts.push(part);
1240 }
1241 if report.preview.lines > 0 {
1242 parts.push(clip.hidden_preview.render(report.preview.lines));
1243 }
1244 if report.details.lines > 0 {
1245 parts.push(clip.hidden_details.render(report.details.lines));
1246 }
1247 if report.choices > 0 {
1248 parts.push(clip.hidden_choices.render(report.choices));
1249 }
1250 if parts.is_empty() {
1251 return (Section::new(0), true);
1252 }
1253 let reveal =
1254 reveal.filter(|_| report.subject.any() || report.preview.any() || report.details.any());
1255 let ascii = theme.ascii();
1256 let build = |parts: &[String], with_reveal: bool| {
1257 let mut sec = Section::new(MAX_ROWS);
1258 sec.line(width, None, |w| {
1259 w.words(&format!("{} ", glyphs::pick("⚠", ascii)), st.attention);
1260 for (i, part) in parts.iter().enumerate() {
1261 if i > 0 {
1262 w.words(dot(ascii), st.border);
1263 }
1264 w.words(part, st.strong);
1265 // The binding sits beside the first count, where a one-row
1266 // notice still shows it.
1267 if let (0, Some(key), true) = (i, &reveal, with_reveal) {
1268 w.words(dot(ascii), st.border);
1269 w.words(key, st.strong);
1270 w.words(&format!(" {}", clip.reveal), st.muted);
1271 }
1272 }
1273 });
1274 sec
1275 };
1276 let want_reveal = reveal.is_some();
1277 let mut variants: Vec<(Vec<String>, bool)> = (1..=parts.len())
1278 .rev()
1279 .map(|n| (parts[..n].to_vec(), want_reveal))
1280 .collect();
1281 if let Some(chars) = first {
1282 variants.push((vec![chars.clone()], want_reveal));
1283 variants.push((vec![chars], false));
1284 }
1285 let last = variants.len() - 1;
1286 for (i, (p, r)) in variants.iter().enumerate() {
1287 let sec = build(p, *r);
1288 if sec.total_rows <= max_rows || i == last {
1289 return (sec, i == 0);
1290 }
1291 }
1292 unreachable!("the last variant always returns")
1293 }
1294
1295 struct Composed {
1296 rows: Vec<Row>,
1297 report: ApprovalPaint,
1298 }
1299
1300 /// A decision: what the agent wants to do, where, and the choices. Paints a
1301 /// bordered overlay panel; hand it the area the card may use.
1302 #[derive(Clone, Debug)]
1303 pub struct ApprovalCard<'a> {
1304 pub subject: &'a ApprovalSubject<'a>,
1305 pub state: &'a ApprovalState,
1306 words: Cow<'a, ApprovalWords>,
1307 flag_non_ascii: bool,
1308 }
1309
1310 impl<'a> ApprovalCard<'a> {
1311 #[must_use]
1312 pub fn new(subject: &'a ApprovalSubject<'a>, state: &'a ApprovalState) -> Self {
1313 Self {
1314 subject,
1315 state,
1316 words: Cow::Owned(ApprovalWords::default()),
1317 flag_non_ascii: true,
1318 }
1319 }
1320
1321 /// Words from the host, by reference.
1322 #[must_use]
1323 pub fn words(mut self, words: &'a ApprovalWords) -> Self {
1324 self.words = Cow::Borrowed(words);
1325 self
1326 }
1327
1328 /// Say when the subject holds non-ASCII text (a homoglyph can pass for a
1329 /// letter). On by default; a host whose people write in other scripts
1330 /// turns it off.
1331 #[must_use]
1332 pub fn flag_non_ascii(mut self, on: bool) -> Self {
1333 self.flag_non_ascii = on;
1334 self
1335 }
1336
1337 fn header(&self, theme: &Theme) -> String {
1338 let mark = glyphs::pick(State::NeedsYou.glyph(), theme.ascii());
1339 format!(
1340 "{mark} {}{}{}",
1341 self.words.needs_you,
1342 dot(theme.ascii()),
1343 self.words.kind(self.subject.kind)
1344 )
1345 }
1346
1347 fn rail(&self, theme: &Theme) -> KeyHints {
1348 let platform = Platform::current(theme.ascii());
1349 KeyHints::new(vec![
1350 KeyHint::new(
1351 keys::pair_label(KeyCode::Left, KeyCode::Right, platform),
1352 self.words.move_focus.clone(),
1353 ),
1354 KeyHint::new("Enter", self.words.choose_focused.clone()),
1355 KeyHint::new("Esc", self.words.cancel.clone()),
1356 ])
1357 }
1358
1359 /// The panel is only the frame: its own rail of hints would cost three
1360 /// rows even where the card is short of them, so the card draws the
1361 /// hints itself, last, and only where there is room.
1362 fn panel(&self, theme: &Theme) -> Panel<'static> {
1363 Panel::new(Depth::Overlay)
1364 .title(self.header(theme))
1365 .focused(true)
1366 }
1367
1368 fn choices_section(&self, width: usize, cap: usize, theme: &Theme, st: &Styles) -> Section {
1369 let platform = Platform::current(theme.ascii());
1370 let selected = theme.bg(Role::Selected);
1371 let items = self
1372 .state
1373 .choices()
1374 .iter()
1375 .enumerate()
1376 .map(|(i, choice)| {
1377 let focused = self.state.focus() == Some(i);
1378 let patch = |s: Style| if focused { s.patch(selected) } else { s };
1379 let mut units = trusted(
1380 glyphs::pick(glyphs::selection_marker(focused), theme.ascii()),
1381 patch(st.primary),
1382 );
1383 units.extend(trusted(" ", patch(st.plain)));
1384 if let Some(key) = choice.keys.first() {
1385 units.extend(trusted(&key.label(platform), patch(st.strong)));
1386 units.extend(trusted(" ", patch(st.plain)));
1387 }
1388 let label = if focused { st.strong } else { st.plain };
1389 units.extend(trusted(&choice.label, patch(label)));
1390 units
1391 })
1392 .collect();
1393 let sep = if theme.ascii() {
1394 trusted(" ", st.plain)
1395 } else {
1396 trusted(dot(false), st.border)
1397 };
1398 pack(items, &sep, width, cap)
1399 }
1400
1401 /// The "not shown" notice for `report`; see [`clip_notice`].
1402 fn notice(
1403 &self,
1404 report: &ApprovalPaint,
1405 width: usize,
1406 max_rows: usize,
1407 theme: &Theme,
1408 st: &Styles,
1409 ) -> (Section, bool) {
1410 let reveal = self
1411 .state
1412 .reveal_key()
1413 .map(|key| key.label(Platform::current(theme.ascii())));
1414 clip_notice(&self.words.clip, report, reveal, width, max_rows, theme, st)
1415 }
1416
1417 /// Lay the card's body out in `width` x `h`. Every part wraps; what does
1418 /// not fit is counted, and the choices come first.
1419 fn compose(&self, width: usize, h: usize, theme: &Theme) -> Composed {
1420 let st = Styles::new(theme);
1421 let ascii = theme.ascii();
1422 let words = &*self.words;
1423 let subject = self.subject;
1424 let cap = h.min(MAX_ROWS);
1425 let mut non_ascii = 0;
1426
1427 // Agent.
1428 let mut agent = Section::new(cap);
1429 if let Some(a) = &subject.agent {
1430 agent.line(width, None, |w| {
1431 w.words(&format!("{} ", words.requested_by), st.muted);
1432 non_ascii += w.subject(&a.name, ascii, &st.looks(st.strong));
1433 if a.sub_agent {
1434 w.words(&format!(" ({})", words.sub_agent), st.muted);
1435 }
1436 });
1437 }
1438
1439 // The subject, verbatim.
1440 let mut main = Section::new(cap);
1441 let first = if subject.kind == ApprovalKind::Command {
1442 "$ ".to_owned()
1443 } else {
1444 format!("{} ", glyphs::pick("›", ascii))
1445 };
1446 let gutter = Some((first.as_str(), st.primary, " ", st.plain));
1447 if subject.target.is_empty() {
1448 main.line(width, gutter, |w| w.words(&words.clip.empty, st.muted));
1449 } else {
1450 main.line(width, gutter, |w| {
1451 non_ascii += w.subject(&subject.target, ascii, &st.looks(st.strong));
1452 });
1453 }
1454
1455 // Elevation and scope: a mark and words, never color alone.
1456 let mut facts = Section::new(cap);
1457 if subject.kind == ApprovalKind::Elevation {
1458 facts.line(width, None, |w| {
1459 w.words(&format!("{} ", glyphs::pick("⚠", ascii)), st.danger);
1460 w.words(&words.elevated, st.danger);
1461 });
1462 }
1463 match subject.scope {
1464 ApprovalScope::Outside => facts.line(width, None, |w| {
1465 w.words(&format!("{} ", glyphs::pick("⚠", ascii)), st.attention);
1466 w.words(&words.outside, st.attention);
1467 }),
1468 ApprovalScope::Inside => facts.line(width, None, |w| w.words(&words.inside, st.muted)),
1469 ApprovalScope::Unchecked => facts.line(width, None, |w| {
1470 w.words(
1471 &format!(
1472 "{} {}",
1473 glyphs::pick(glyphs::UNKNOWN, ascii),
1474 words.unchecked
1475 ),
1476 st.muted,
1477 );
1478 }),
1479 }
1480
1481 let mut cwd = Section::new(cap);
1482 if let Some(dir) = &subject.cwd {
1483 cwd.line(width, None, |w| {
1484 w.words(&format!("{} ", words.in_dir), st.muted);
1485 non_ascii += w.subject(dir, ascii, &st.looks(st.plain));
1486 });
1487 }
1488
1489 let mut notes = Section::new(cap);
1490 for note in &subject.risk_notes {
1491 notes.line(width, None, |w| {
1492 w.words(&format!("{}: ", words.risk), st.attention);
1493 w.prose(note, ascii, &st.looks(st.plain));
1494 });
1495 }
1496
1497 let mut preview = Section::new(cap);
1498 if !subject.preview.is_empty() {
1499 preview.line(width, None, |w| w.words(&words.preview, st.muted));
1500 let rail = format!("{} ", glyphs::pick("│", ascii));
1501 for line in &subject.preview {
1502 let gutter = Some((rail.as_str(), st.border, rail.as_str(), st.border));
1503 preview.line(width, gutter, |w| {
1504 w.prose(line, ascii, &st.looks(st.plain));
1505 });
1506 }
1507 }
1508
1509 let mut flag = Section::new(cap);
1510 if self.flag_non_ascii && non_ascii > 0 {
1511 flag.line(width, None, |w| {
1512 w.words(&format!("{} ", glyphs::pick("⚠", ascii)), st.attention);
1513 w.words(&words.non_ascii.render(non_ascii), st.attention);
1514 });
1515 }
1516
1517 let choices = self.choices_section(width, cap, theme, &st);
1518
1519 // Display order. Priority: the first row of the subject, then where
1520 // and how (the facts), the rest of the subject, the agent, risk
1521 // notes, the non-ASCII note, the directory and the preview.
1522 let sections = [&agent, &main, &facts, &cwd, &notes, &preview, &flag];
1523 let steps: [(usize, usize); 8] = [
1524 (1, 1),
1525 (2, usize::MAX),
1526 (1, usize::MAX),
1527 (0, usize::MAX),
1528 (4, usize::MAX),
1529 (6, usize::MAX),
1530 (3, usize::MAX),
1531 (5, usize::MAX),
1532 ];
1533 let body_total: usize = sections.iter().map(|s| s.total_rows).sum();
1534 let mut choice_rows = choices.rows.len().min(h);
1535 if choice_rows == h && h > 0 {
1536 // No row would be left for the notice that must say the body was
1537 // cut: it takes the last choice row. A card that shows its
1538 // choices and hides what they are for would be a blind approval
1539 // with a button.
1540 choice_rows -= 1;
1541 }
1542 let after_choices = h - choice_rows;
1543
1544 let choices_hidden = choices.total_chars - choices_shown(&choices, choice_rows);
1545 // Hand out `after_choices - reserve` rows in priority order.
1546 let allocate = |reserve: usize| {
1547 let mut left = after_choices - reserve;
1548 let mut taken = [0usize; 7];
1549 for (i, upto) in steps {
1550 let more = (sections[i].total_rows.min(upto) - taken[i].min(upto)).min(left);
1551 taken[i] += more;
1552 left -= more;
1553 }
1554 let sum = |ids: &[usize]| {
1555 ids.iter().fold(ApprovalHidden::default(), |acc, &i| {
1556 acc.plus(sections[i].hidden(taken[i]))
1557 })
1558 };
1559 let report = ApprovalPaint {
1560 subject: sections[1].hidden(taken[1]),
1561 preview: sections[5].hidden(taken[5]),
1562 details: sum(&[0, 2, 3, 4, 6]),
1563 choices: choices_hidden,
1564 non_ascii,
1565 };
1566 (taken, left, report)
1567 };
1568
1569 // If the body cannot all fit, reserve the fewest rows that hold the
1570 // notice saying so: every row spent on the notice is one fewer for
1571 // the card's facts.
1572 let mut reserve = 0;
1573 if body_total > after_choices {
1574 reserve = 1.min(after_choices);
1575 while reserve < after_choices {
1576 let (_, _, report) = allocate(reserve);
1577 if self.notice(&report, width, reserve, theme, &st).1 {
1578 break;
1579 }
1580 reserve += 1;
1581 }
1582 }
1583 let (taken, mut left, report) = allocate(reserve);
1584
1585 let mut rows = Vec::new();
1586 for (i, sec) in sections.iter().enumerate() {
1587 rows.extend(sec.rows[..taken[i].min(sec.rows.len())].iter().cloned());
1588 }
1589 if report.clipped() {
1590 let (notice, _) = self.notice(&report, width, reserve, theme, &st);
1591 rows.extend(notice.rows.into_iter().take(reserve));
1592 } else if left > 0 {
1593 rows.push(Row {
1594 spans: Vec::new(),
1595 units: 0,
1596 });
1597 left -= 1;
1598 }
1599 rows.extend(choices.rows.iter().take(choice_rows).cloned());
1600 // The key hints come last, and only where there is room for them.
1601 let rail = self
1602 .rail(theme)
1603 .lines(u16::try_from(width).unwrap_or(u16::MAX), theme);
1604 if !report.clipped() && !rail.is_empty() && left > rail.len() {
1605 rows.push(Row {
1606 spans: Vec::new(),
1607 units: 0,
1608 });
1609 rows.extend(rail.into_iter().map(|line| Row {
1610 spans: line.spans,
1611 units: 0,
1612 }));
1613 }
1614 Composed { rows, report }
1615 }
1616
1617 /// Paint the card and say what it did not show.
1618 pub fn render(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> ApprovalPaint {
1619 let area = area.intersection(buf.area);
1620 let inner = self.panel(theme).draw(area, buf, theme);
1621 let inner = inner.intersection(area);
1622 let composed = self.compose(usize::from(inner.width), usize::from(inner.height), theme);
1623 paint_rows(&composed.rows, inner, buf);
1624 composed.report
1625 }
1626 }
1627
1628 fn choices_shown(choices: &Section, shown_rows: usize) -> usize {
1629 choices.rows[..shown_rows.min(choices.rows.len())]
1630 .iter()
1631 .map(|r| r.units)
1632 .sum()
1633 }
1634
1635 fn paint_rows(rows: &[Row], area: Rect, buf: &mut Buffer) {
1636 for (i, row) in rows.iter().enumerate().take(usize::from(area.height)) {
1637 let y = area.y + u16::try_from(i).unwrap_or(u16::MAX);
1638 buf.set_line(area.x, y, &Line::from(row.spans.clone()), area.width);
1639 }
1640 }
1641
1642 impl Paint for ApprovalCard<'_> {
1643 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1644 self.render(area, buf, theme);
1645 }
1646
1647 /// Rows that show everything at `width`.
1648 fn height(&self, width: u16, theme: &Theme) -> u16 {
1649 let panel = self.panel(theme);
1650 let probe = |h: u16| {
1651 let area = Rect::new(0, 0, width, h);
1652 panel.draw(area, &mut Buffer::empty(area), theme)
1653 };
1654 let rows = self
1655 .compose(usize::from(probe(64).width), MAX_ROWS, theme)
1656 .rows
1657 .len();
1658 let wanted = u16::try_from(rows).unwrap_or(u16::MAX / 2);
1659 (wanted..=wanted.saturating_add(16))
1660 .find(|h| usize::from(probe(*h).height) >= rows)
1661 .unwrap_or_else(|| wanted.saturating_add(16))
1662 }
1663 }
1664
1665 // ---------------------------------------------------------------------------
1666 // Review verdicts
1667 // ---------------------------------------------------------------------------
1668
1669 /// What an automated reviewer concluded.
1670 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1671 pub enum ReviewKind {
1672 /// The reviewer let the action proceed.
1673 Allowed,
1674 /// The reviewer stopped the action.
1675 Denied,
1676 /// The reviewer set the action aside for the person.
1677 Held,
1678 /// The review failed or timed out. This is not a verdict about the
1679 /// action, and it never looks like one.
1680 Undecided,
1681 }
1682
1683 impl ReviewKind {
1684 pub const ALL: [ReviewKind; 4] = [
1685 ReviewKind::Allowed,
1686 ReviewKind::Denied,
1687 ReviewKind::Held,
1688 ReviewKind::Undecided,
1689 ];
1690
1691 /// The product state whose mark and hue this verdict wears. Four marks:
1692 /// `✓` `✕` `◆` `?`, which stay four different shapes in ASCII.
1693 #[must_use]
1694 pub const fn state(self) -> State {
1695 match self {
1696 ReviewKind::Allowed => State::Done,
1697 ReviewKind::Denied => State::Failed,
1698 ReviewKind::Held => State::NeedsYou,
1699 ReviewKind::Undecided => State::Unknown,
1700 }
1701 }
1702
1703 /// Whether the person has to do something about this verdict.
1704 #[must_use]
1705 pub const fn needs_a_next_step(self) -> bool {
1706 !matches!(self, ReviewKind::Allowed)
1707 }
1708 }
1709
1710 /// The words of a verdict. English by default.
1711 #[derive(Clone, Debug, PartialEq, Eq)]
1712 pub struct ReviewWords {
1713 pub allowed: Cow<'static, str>,
1714 pub denied: Cow<'static, str>,
1715 pub held: Cow<'static, str>,
1716 pub undecided: Cow<'static, str>,
1717 pub why: Cow<'static, str>,
1718 pub waiting_for: Cow<'static, str>,
1719 /// Under a held verdict.
1720 pub held_note: Cow<'static, str>,
1721 /// Under an undecided one: it is not a verdict.
1722 pub undecided_note: Cow<'static, str>,
1723 pub clip: ApprovalClipWords,
1724 }
1725
1726 impl Default for ReviewWords {
1727 fn default() -> Self {
1728 Self {
1729 allowed: "Allowed".into(),
1730 denied: "Denied".into(),
1731 held: "Held for you".into(),
1732 undecided: "Could not decide".into(),
1733 why: "Why".into(),
1734 waiting_for: "Waiting for".into(),
1735 held_note: "Your call.".into(),
1736 undecided_note: "No verdict. This is not a safety decision: your call.".into(),
1737 clip: ApprovalClipWords::default(),
1738 }
1739 }
1740 }
1741
1742 impl ReviewWords {
1743 fn word(&self, kind: ReviewKind) -> &str {
1744 match kind {
1745 ReviewKind::Allowed => &self.allowed,
1746 ReviewKind::Denied => &self.denied,
1747 ReviewKind::Held => &self.held,
1748 ReviewKind::Undecided => &self.undecided,
1749 }
1750 }
1751 }
1752
1753 /// A held, denied or undecided verdict that offers no next step.
1754 #[derive(Clone, Debug, PartialEq, Eq)]
1755 pub struct ReviewError;
1756
1757 impl std::fmt::Display for ReviewError {
1758 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1759 write!(
1760 f,
1761 "a denied, held or undecided review needs an enabled key or a waiting-for line"
1762 )
1763 }
1764 }
1765
1766 impl std::error::Error for ReviewError {}
1767
1768 /// An automated reviewer's verdict on one action, in the transcript.
1769 #[derive(Clone, Debug)]
1770 pub struct ReviewVerdict<'a> {
1771 pub kind: ReviewKind,
1772 pub reviewer: Cow<'a, str>,
1773 /// One line: why. Wrapped, never cut.
1774 pub reason: Cow<'a, str>,
1775 /// The action, verbatim, when the verdict should show it.
1776 pub subject: Option<Cow<'a, str>>,
1777 /// A plain word after the reviewer: "Deletes unseen files". No hue.
1778 pub category: Option<Cow<'a, str>>,
1779 /// What must happen first, as text with no key.
1780 pub waiting_for: Option<Cow<'a, str>>,
1781 /// Right-aligned in the quietest ink when it fits.
1782 pub id: Option<Cow<'a, str>>,
1783 /// What the person can do about it.
1784 pub hints: &'a KeyHints,
1785 words: Cow<'a, ReviewWords>,
1786 }
1787
1788 impl<'a> ReviewVerdict<'a> {
1789 /// A denied, held or undecided verdict must say what changes its outcome:
1790 /// an enabled key in `hints`, or a `waiting_for` line. Otherwise the
1791 /// person is told something stopped and given nothing to do about it.
1792 pub fn new(
1793 kind: ReviewKind,
1794 reviewer: impl Into<Cow<'a, str>>,
1795 reason: impl Into<Cow<'a, str>>,
1796 hints: &'a KeyHints,
1797 waiting_for: Option<Cow<'a, str>>,
1798 ) -> Result<Self, ReviewError> {
1799 let has_key = hints.items.iter().any(|h| h.enabled);
1800 let has_wait = waiting_for.as_deref().is_some_and(|w| !w.trim().is_empty());
1801 if kind.needs_a_next_step() && !has_key && !has_wait {
1802 return Err(ReviewError);
1803 }
1804 Ok(Self {
1805 kind,
1806 reviewer: reviewer.into(),
1807 reason: reason.into(),
1808 subject: None,
1809 category: None,
1810 waiting_for,
1811 id: None,
1812 hints,
1813 words: Cow::Owned(ReviewWords::default()),
1814 })
1815 }
1816
1817 #[must_use]
1818 pub fn subject(mut self, subject: impl Into<Cow<'a, str>>) -> Self {
1819 self.subject = Some(subject.into());
1820 self
1821 }
1822
1823 #[must_use]
1824 pub fn category(mut self, category: impl Into<Cow<'a, str>>) -> Self {
1825 self.category = Some(category.into());
1826 self
1827 }
1828
1829 #[must_use]
1830 pub fn id(mut self, id: impl Into<Cow<'a, str>>) -> Self {
1831 self.id = Some(id.into());
1832 self
1833 }
1834
1835 #[must_use]
1836 pub fn words(mut self, words: &'a ReviewWords) -> Self {
1837 self.words = Cow::Borrowed(words);
1838 self
1839 }
1840
1841 fn compose(&self, width: usize, h: usize, theme: &Theme) -> Composed {
1842 let st = Styles::new(theme);
1843 let ascii = theme.ascii();
1844 let words = &*self.words;
1845 let cap = h.min(MAX_ROWS);
1846 let state = self.kind.state();
1847 let hue = theme.fg(state.role());
1848
1849 let mut head = Section::new(cap);
1850 head.line(width, None, |w| {
1851 w.words(&format!("{} ", glyphs::pick(state.glyph(), ascii)), hue);
1852 w.words(words.word(self.kind), st.strong);
1853 w.words(dot(ascii), st.border);
1854 w.subject(&self.reviewer, ascii, &st.looks(st.muted));
1855 if let Some(category) = &self.category {
1856 w.words(dot(ascii), st.border);
1857 w.words(category, st.muted);
1858 }
1859 });
1860 if let (Some(id), 1, Some(row)) = (&self.id, head.total_rows, head.rows.first_mut()) {
1861 let used: usize = row.spans.iter().map(|s| text::width(&s.content)).sum();
1862 let id = text::display_safe(id);
1863 let id_w = text::width(&id);
1864 if used + 2 + id_w <= width {
1865 row.spans.push(Span::raw(" ".repeat(width - used - id_w)));
1866 row.spans.push(Span::styled(id.into_owned(), st.hint));
1867 }
1868 }
1869
1870 let mut subject = Section::new(cap);
1871 if let Some(src) = &self.subject {
1872 let gutter = Some(("$ ", st.primary, " ", st.plain));
1873 subject.line(width, gutter, |w| {
1874 w.subject(src, ascii, &st.looks(st.strong));
1875 });
1876 }
1877
1878 let mut reason = Section::new(cap);
1879 reason.line(width, None, |w| {
1880 w.words(&format!("{}: ", words.why), st.muted);
1881 w.prose(&self.reason, ascii, &st.looks(st.plain));
1882 });
1883
1884 let mut note = Section::new(cap);
1885 let note_text = match self.kind {
1886 ReviewKind::Held => Some(&words.held_note),
1887 ReviewKind::Undecided => Some(&words.undecided_note),
1888 ReviewKind::Allowed | ReviewKind::Denied => None,
1889 };
1890 if let Some(n) = note_text {
1891 note.line(width, None, |w| w.words(n, st.strong));
1892 }
1893 let mut waiting = Section::new(cap);
1894 if let Some(wait) = &self.waiting_for {
1895 waiting.line(width, None, |w| {
1896 w.words(&format!("{}: ", words.waiting_for), st.muted);
1897 w.prose(wait, ascii, &st.looks(st.plain));
1898 });
1899 }
1900
1901 // A key that would do nothing is not shown.
1902 let enabled = KeyHints::new(
1903 self.hints
1904 .items
1905 .iter()
1906 .filter(|h| h.enabled)
1907 .cloned()
1908 .collect(),
1909 );
1910 let hint_lines = enabled.lines(u16::try_from(width).unwrap_or(u16::MAX), theme);
1911 let mut hints = Section::new(cap);
1912 for line in hint_lines {
1913 hints.total_rows += 1;
1914 if hints.rows.len() < cap {
1915 hints.rows.push(Row {
1916 spans: line.spans,
1917 units: 0,
1918 });
1919 }
1920 }
1921
1922 // Display order: head, subject, reason, note, waiting, notice, hints.
1923 // Priority: the head, the hints, the action, then the reasons.
1924 let sections = [&head, &subject, &reason, &note, &waiting, &hints];
1925 let priority = [0, 5, 1, 2, 3, 4];
1926 let body_total: usize = sections.iter().map(|s| s.total_rows).sum();
1927 let allocate = |reserve: usize| {
1928 let mut left = h - reserve;
1929 let mut taken = [0usize; 6];
1930 for i in priority {
1931 taken[i] = sections[i].total_rows.min(left);
1932 left -= taken[i];
1933 }
1934 let report = ApprovalPaint {
1935 subject: subject.hidden(taken[1]),
1936 preview: ApprovalHidden::default(),
1937 details: head
1938 .hidden(taken[0])
1939 .plus(reason.hidden(taken[2]))
1940 .plus(note.hidden(taken[3]))
1941 .plus(waiting.hidden(taken[4])),
1942 choices: hints.total_rows.saturating_sub(taken[5]),
1943 non_ascii: 0,
1944 };
1945 (taken, report)
1946 };
1947 let mut reserve = 0;
1948 if body_total > h {
1949 reserve = 1.min(h);
1950 while reserve < h {
1951 let (_, report) = allocate(reserve);
1952 if clip_notice(&words.clip, &report, None, width, reserve, theme, &st).1 {
1953 break;
1954 }
1955 reserve += 1;
1956 }
1957 }
1958 let (taken, report) = allocate(reserve);
1959 let mut rows = Vec::new();
1960 for i in [0, 1, 2, 3, 4] {
1961 rows.extend(
1962 sections[i].rows[..taken[i].min(sections[i].rows.len())]
1963 .iter()
1964 .cloned(),
1965 );
1966 }
1967 if report.clipped() {
1968 let (notice, _) = clip_notice(&words.clip, &report, None, width, reserve, theme, &st);
1969 rows.extend(notice.rows.into_iter().take(reserve));
1970 }
1971 rows.extend(hints.rows[..taken[5].min(hints.rows.len())].iter().cloned());
1972 Composed { rows, report }
1973 }
1974
1975 /// Paint the verdict and say what it did not show.
1976 pub fn render(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> ApprovalPaint {
1977 let area = area.intersection(buf.area);
1978 let composed = self.compose(usize::from(area.width), usize::from(area.height), theme);
1979 paint_rows(&composed.rows, area, buf);
1980 composed.report
1981 }
1982 }
1983
1984 impl Paint for ReviewVerdict<'_> {
1985 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1986 self.render(area, buf, theme);
1987 }
1988
1989 fn height(&self, width: u16, theme: &Theme) -> u16 {
1990 let rows = self.compose(usize::from(width), MAX_ROWS, theme).rows.len();
1991 u16::try_from(rows).unwrap_or(u16::MAX)
1992 }
1993 }
1994
1995 // ---------------------------------------------------------------------------
1996 // The aggregate
1997 // ---------------------------------------------------------------------------
1998
1999 /// The words of the aggregate row.
2000 #[derive(Clone, Debug, PartialEq, Eq)]
2001 pub struct ReviewAggregateWords {
2002 pub title: Cow<'static, str>,
2003 pub allowed: Cow<'static, str>,
2004 pub denied: Cow<'static, str>,
2005 pub held: Cow<'static, str>,
2006 pub undecided: Cow<'static, str>,
2007 pub reviewing: Cow<'static, str>,
2008 pub none_yet: Cow<'static, str>,
2009 }
2010
2011 impl Default for ReviewAggregateWords {
2012 fn default() -> Self {
2013 Self {
2014 title: "Auto-Review".into(),
2015 allowed: "allowed".into(),
2016 denied: "denied".into(),
2017 held: "held for you".into(),
2018 undecided: "could not decide".into(),
2019 reviewing: "reviewing".into(),
2020 none_yet: "nothing checked yet".into(),
2021 }
2022 }
2023 }
2024
2025 /// Several verdicts as one row with real counts:
2026 /// `Auto-Review · ✓ 42 allowed · ✕ 3 denied · ◆ 1 held for you · ? 1 could
2027 /// not decide`. A count of zero is left out; a row of zeros says nothing has
2028 /// been checked. Static: no spinner and no highlight.
2029 #[derive(Clone, Debug, Default, PartialEq, Eq)]
2030 pub struct ReviewAggregate {
2031 pub allowed: u32,
2032 pub denied: u32,
2033 pub held: u32,
2034 pub undecided: u32,
2035 /// Reviews running now, shown as `● reviewing 2`.
2036 pub reviewing: u32,
2037 pub words: ReviewAggregateWords,
2038 }
2039
2040 impl ReviewAggregate {
2041 #[must_use]
2042 pub fn new() -> Self {
2043 Self::default()
2044 }
2045
2046 /// Count verdicts.
2047 #[must_use]
2048 pub fn from_kinds(kinds: impl IntoIterator<Item = ReviewKind>) -> Self {
2049 let mut agg = Self::default();
2050 for kind in kinds {
2051 match kind {
2052 ReviewKind::Allowed => agg.allowed += 1,
2053 ReviewKind::Denied => agg.denied += 1,
2054 ReviewKind::Held => agg.held += 1,
2055 ReviewKind::Undecided => agg.undecided += 1,
2056 }
2057 }
2058 agg
2059 }
2060
2061 #[must_use]
2062 pub fn reviewing(mut self, n: u32) -> Self {
2063 self.reviewing = n;
2064 self
2065 }
2066
2067 fn section(&self, width: usize, cap: usize, theme: &Theme) -> Section {
2068 let st = Styles::new(theme);
2069 let ascii = theme.ascii();
2070 let words = &self.words;
2071 let mut items = vec![trusted(&words.title, st.strong)];
2072 let counted = [
2073 (ReviewKind::Allowed, self.allowed, &words.allowed),
2074 (ReviewKind::Denied, self.denied, &words.denied),
2075 (ReviewKind::Held, self.held, &words.held),
2076 (ReviewKind::Undecided, self.undecided, &words.undecided),
2077 ];
2078 let mut any = false;
2079 for (kind, n, word) in counted {
2080 if n == 0 {
2081 continue;
2082 }
2083 any = true;
2084 let state = kind.state();
2085 let mut unit = trusted(
2086 &format!("{} ", glyphs::pick(state.glyph(), ascii)),
2087 theme.fg(state.role()),
2088 );
2089 unit.extend(trusted(&format!("{n} "), st.strong));
2090 unit.extend(trusted(word, st.plain));
2091 items.push(unit);
2092 }
2093 if self.reviewing > 0 {
2094 any = true;
2095 let mut unit = trusted(
2096 &format!("{} ", glyphs::pick(State::Working.glyph(), ascii)),
2097 theme.fg(State::Working.role()),
2098 );
2099 unit.extend(trusted(&format!("{} ", words.reviewing), st.plain));
2100 unit.extend(trusted(&self.reviewing.to_string(), st.strong));
2101 items.push(unit);
2102 }
2103 if !any {
2104 items.push(trusted(&words.none_yet, st.muted));
2105 }
2106 let sep = if ascii {
2107 trusted(" ", st.plain)
2108 } else {
2109 trusted(dot(ascii), st.border)
2110 };
2111 pack(items, &sep, width, cap)
2112 }
2113
2114 /// Paint the row; returns how many items did not fit.
2115 pub fn render(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> usize {
2116 let area = area.intersection(buf.area);
2117 let sec = self.section(usize::from(area.width), usize::from(area.height), theme);
2118 let hidden = sec.total_chars - choices_shown(&sec, sec.rows.len());
2119 paint_rows(&sec.rows, area, buf);
2120 hidden
2121 }
2122 }
2123
2124 impl Paint for ReviewAggregate {
2125 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
2126 self.render(area, buf, theme);
2127 }
2128
2129 fn height(&self, width: u16, theme: &Theme) -> u16 {
2130 let sec = self.section(usize::from(width), MAX_ROWS, theme);
2131 u16::try_from(sec.total_rows).unwrap_or(u16::MAX)
2132 }
2133 }
2134
2135 // ---------------------------------------------------------------------------
2136 // Native bottom decision band over host-projected display facts
2137 // ---------------------------------------------------------------------------
2138
2139 /// A caller-owned option in canonical decision order. The band never handles
2140 /// its key or decides its effect. A withheld persistent action retains an empty
2141 /// rectangle at this index so pointer dispatch cannot change its meaning.
2142 #[derive(Clone, Debug)]
2143 pub struct DecisionBandAction {
2144 pub line: Line<'static>,
2145 pub persistent: bool,
2146 }
2147
2148 /// Validated rule coverage from the host. The band owns full/compact display
2149 /// fitting; it never reparses a command or constructs a permission rule.
2150 #[derive(Clone, Debug)]
2151 pub struct DecisionBandSave {
2152 pub summary: String,
2153 pub entries: Vec<String>,
2154 pub omitted: usize,
2155 pub label: String,
2156 pub separator: String,
2157 /// `{count}` is replaced with the number of additional rules.
2158 pub compact_more: String,
2159 pub full_more: String,
2160 pub label_style: Style,
2161 pub summary_style: Style,
2162 pub entries_style: Style,
2163 pub more_style: Style,
2164 }
2165
2166 impl DecisionBandSave {
2167 fn lines(&self, width: u16, compact: bool) -> Vec<Line<'static>> {
2168 let entries = self.entries.join("; ");
2169 if compact {
2170 let more = if self.omitted > 0 {
2171 self.compact_more
2172 .replace("{count}", &self.omitted.to_string())
2173 } else {
2174 String::new()
2175 };
2176 let budget = usize::from(width)
2177 .saturating_sub(
2178 2 + self.label.chars().count()
2179 + self.summary.chars().count()
2180 + self.separator.chars().count()
2181 + more.chars().count(),
2182 )
2183 .max(12);
2184 return vec![Line::from(vec![
2185 Span::raw(" "),
2186 Span::styled(self.label.clone(), self.label_style),
2187 Span::styled(self.summary.clone(), self.summary_style),
2188 Span::styled(
2189 format!(
2190 "{}{}{more}",
2191 self.separator,
2192 band_byte_clip(&entries, budget)
2193 ),
2194 self.entries_style,
2195 ),
2196 ])];
2197 }
2198 let mut lines = vec![
2199 Line::from(vec![
2200 Span::raw(" "),
2201 Span::styled(self.label.clone(), self.label_style),
2202 Span::styled(self.summary.clone(), self.summary_style),
2203 ]),
2204 Line::from(vec![
2205 Span::raw(" "),
2206 Span::styled(
2207 band_byte_clip(&entries, usize::from(width.saturating_sub(10)).max(20)),
2208 self.entries_style,
2209 ),
2210 ]),
2211 ];
2212 if self.omitted > 0 {
2213 lines.push(Line::from(vec![
2214 Span::raw(" "),
2215 Span::styled(
2216 self.full_more.replace("{count}", &self.omitted.to_string()),
2217 self.more_style,
2218 ),
2219 ]));
2220 }
2221 lines
2222 }
2223 }
2224
2225 fn band_byte_clip(value: &str, budget: usize) -> String {
2226 // Retain the native host's existing UTF-8-boundary preview contract.
2227 // This coverage is informational; full validated rules stay with the host.
2228 if value.len() <= budget {
2229 return value.to_owned();
2230 }
2231 let budget = budget.saturating_sub(3);
2232 let end = value
2233 .char_indices()
2234 .map(|(index, _)| index)
2235 .take_while(|index| *index <= budget)
2236 .last()
2237 .unwrap_or(0);
2238 format!("{}...", &value[..end])
2239 }
2240
2241 fn band_safe_span(span: &Span<'static>) -> Span<'static> {
2242 let mut safe = span.clone();
2243 if let Cow::Owned(value) = text::display_safe(&span.content) {
2244 safe.content = Cow::Owned(value);
2245 }
2246 safe
2247 }
2248
2249 fn band_safe_line(line: &Line<'static>) -> Line<'static> {
2250 let mut safe = line.clone();
2251 safe.spans = line.spans.iter().map(band_safe_span).collect();
2252 safe
2253 }
2254
2255 /// Exact Ratatui word-wrap measurement, shared by band layout and painting.
2256 #[must_use]
2257 pub fn decision_wrapped_rows(lines: &[Line<'_>], width: u16) -> u16 {
2258 use ratatui::widgets::{Paragraph, Wrap};
2259 let safe: Vec<_> = lines
2260 .iter()
2261 .map(|line| {
2262 let mut safe = line.clone();
2263 for span in &mut safe.spans {
2264 if let Cow::Owned(value) = text::display_safe(&span.content) {
2265 span.content = Cow::Owned(value);
2266 }
2267 }
2268 safe
2269 })
2270 .collect();
2271 let rows = if width == 0 {
2272 lines.len()
2273 } else {
2274 Paragraph::new(safe)
2275 .wrap(Wrap { trim: false })
2276 .line_count(width)
2277 };
2278 u16::try_from(rows).unwrap_or(u16::MAX)
2279 }
2280
2281 /// A native approval band over already-projected host display facts. Labels,
2282 /// styles, badges, risk/effect/owner data and decisions remain host authority.
2283 /// Every span and coverage string is run through [`text::display_safe`] before
2284 /// measurement and painting, preserving styles while removing bidi/controls.
2285 /// These are logical lines; final word-wrap, fit, save visibility, paint and
2286 /// pointer rectangles belong to the single plan below. No input handler or
2287 /// ApprovalState is constructed for this presentation.
2288 #[derive(Clone, Debug)]
2289 pub struct DecisionBand {
2290 pub body: Vec<Line<'static>>,
2291 pub saves: Vec<DecisionBandSave>,
2292 pub question: Line<'static>,
2293 pub actions: Vec<DecisionBandAction>,
2294 pub footer: Line<'static>,
2295 pub save_hint: Option<Span<'static>>,
2296 pub background: Style,
2297 pub rule: Span<'static>,
2298 pub truncation_hint: Span<'static>,
2299 pub collapsed: Option<Line<'static>>,
2300 }
2301
2302 /// The complete painted contract. Empty action boxes remain at their original
2303 /// indices. A host can enable persistent keys only while `save_shown` is true.
2304 #[derive(Clone, Debug)]
2305 pub struct DecisionBandPlan {
2306 pub region: Rect,
2307 pub body_rect: Rect,
2308 pub control_rect: Rect,
2309 pub save_rect: Rect,
2310 pub action_rects: Vec<Rect>,
2311 pub save_shown: bool,
2312 background: Style,
2313 rule: Span<'static>,
2314 hint: Span<'static>,
2315 head: Vec<Line<'static>>,
2316 save: Vec<Line<'static>>,
2317 controls: Vec<Line<'static>>,
2318 head_truncated: bool,
2319 collapsed: Option<Line<'static>>,
2320 }
2321
2322 impl DecisionBand {
2323 fn controls(&self, offer_save: bool) -> Vec<Line<'static>> {
2324 let mut controls = vec![self.question.clone()];
2325 controls.extend(
2326 self.actions
2327 .iter()
2328 .filter(|action| offer_save || !action.persistent)
2329 .map(|action| action.line.clone()),
2330 );
2331 let mut footer = self.footer.clone();
2332 if offer_save && let Some(hint) = &self.save_hint {
2333 footer.spans.push(hint.clone());
2334 }
2335 controls.push(footer);
2336 controls
2337 }
2338
2339 #[must_use]
2340 pub fn plan(&self, area: Rect) -> DecisionBandPlan {
2341 self.sanitized().plan_safe(area)
2342 }
2343
2344 fn sanitized(&self) -> Self {
2345 let mut safe = self.clone();
2346 safe.body = self.body.iter().map(band_safe_line).collect();
2347 safe.question = band_safe_line(&self.question);
2348 for action in &mut safe.actions {
2349 action.line = band_safe_line(&action.line);
2350 }
2351 safe.footer = band_safe_line(&self.footer);
2352 safe.save_hint = self.save_hint.as_ref().map(band_safe_span);
2353 safe.rule = band_safe_span(&self.rule);
2354 safe.truncation_hint = band_safe_span(&self.truncation_hint);
2355 safe.collapsed = self.collapsed.as_ref().map(band_safe_line);
2356 for save in &mut safe.saves {
2357 save.summary = text::display_safe(&save.summary).into_owned();
2358 for entry in &mut save.entries {
2359 *entry = text::display_safe(entry).into_owned();
2360 }
2361 save.label = text::display_safe(&save.label).into_owned();
2362 save.separator = text::display_safe(&save.separator).into_owned();
2363 save.compact_more = text::display_safe(&save.compact_more).into_owned();
2364 save.full_more = text::display_safe(&save.full_more).into_owned();
2365 }
2366 safe
2367 }
2368
2369 fn plan_safe(&self, area: Rect) -> DecisionBandPlan {
2370 let mut result = DecisionBandPlan {
2371 region: Rect::new(area.x, area.bottom(), 0, 0),
2372 body_rect: Rect::default(),
2373 control_rect: Rect::default(),
2374 save_rect: Rect::default(),
2375 action_rects: vec![Rect::default(); self.actions.len()],
2376 save_shown: false,
2377 background: self.background,
2378 rule: self.rule.clone(),
2379 hint: self.truncation_hint.clone(),
2380 head: Vec::new(),
2381 save: Vec::new(),
2382 controls: Vec::new(),
2383 head_truncated: false,
2384 collapsed: None,
2385 };
2386 if area.is_empty() {
2387 return result;
2388 }
2389 if let Some(line) = &self.collapsed {
2390 result.region = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1);
2391 result.collapsed = Some(line.clone());
2392 return result;
2393 }
2394 let compact_save: Vec<_> = self
2395 .saves
2396 .iter()
2397 .flat_map(|save| save.lines(area.width, true))
2398 .collect();
2399 let save_reserve = decision_wrapped_rows(&compact_save, area.width);
2400 let controls_with_save = self.controls(true);
2401 let mut compact_body = self.body.clone();
2402 compact_body.extend(compact_save.iter().cloned());
2403 let compact_region = band_region(area, &compact_body, save_reserve, &controls_with_save);
2404 let compact_inner = compact_region.height.saturating_sub(1);
2405 let controls_rows =
2406 decision_wrapped_rows(&controls_with_save, area.width).min(compact_inner);
2407 let save_shown =
2408 !compact_save.is_empty() && save_reserve <= compact_inner.saturating_sub(controls_rows);
2409 let (body, save, controls, reserve) = if save_shown {
2410 let full_save: Vec<_> = self
2411 .saves
2412 .iter()
2413 .flat_map(|save| save.lines(area.width, false))
2414 .collect();
2415 let mut full_body = self.body.clone();
2416 full_body.extend(full_save.iter().cloned());
2417 let full_region = band_region(area, &full_body, save_reserve, &controls_with_save);
2418 let full_inner = full_region.height.saturating_sub(1);
2419 let full_controls =
2420 decision_wrapped_rows(&controls_with_save, area.width).min(full_inner);
2421 if decision_wrapped_rows(&full_body, area.width)
2422 <= full_inner.saturating_sub(full_controls)
2423 {
2424 (full_body, full_save, controls_with_save, save_reserve)
2425 } else {
2426 (compact_body, compact_save, controls_with_save, save_reserve)
2427 }
2428 } else {
2429 (self.body.clone(), Vec::new(), self.controls(false), 0)
2430 };
2431 result.region = band_region(area, &body, reserve, &controls);
2432 result.save_shown = save_shown;
2433 let inner = result.region.height.saturating_sub(1);
2434 let control_rows = decision_wrapped_rows(&controls, area.width).min(inner);
2435 let body_height = inner.saturating_sub(control_rows);
2436 result.body_rect = Rect::new(
2437 area.x,
2438 result.region.y.saturating_add(1),
2439 area.width,
2440 body_height,
2441 );
2442 result.control_rect =
2443 Rect::new(area.x, result.body_rect.bottom(), area.width, control_rows);
2444 let body_truncated = decision_wrapped_rows(&body, area.width) > body_height;
2445 let save_rows = if body_truncated {
2446 decision_wrapped_rows(&save, area.width).min(body_height)
2447 } else {
2448 decision_wrapped_rows(&save, area.width)
2449 };
2450 result.save_rect = Rect::new(
2451 area.x,
2452 if body_truncated {
2453 result.body_rect.bottom().saturating_sub(save_rows)
2454 } else {
2455 result
2456 .body_rect
2457 .y
2458 .saturating_add(decision_wrapped_rows(&self.body, area.width))
2459 },
2460 area.width,
2461 save_rows,
2462 );
2463 result.head_truncated = body_truncated;
2464 result.head = if body_truncated {
2465 self.body.clone()
2466 } else {
2467 body
2468 };
2469 result.save = if body_truncated { save } else { Vec::new() };
2470 let mut shown_index = 0;
2471 for (index, action) in self.actions.iter().enumerate() {
2472 if action.persistent && !save_shown {
2473 continue;
2474 }
2475 let first = 1 + shown_index;
2476 shown_index += 1;
2477 let top = decision_wrapped_rows(&controls[..first], area.width);
2478 let bottom = decision_wrapped_rows(&controls[..first + 1], area.width);
2479 let y = result.control_rect.y.saturating_add(top);
2480 let height = bottom
2481 .saturating_sub(top)
2482 .min(result.control_rect.bottom().saturating_sub(y));
2483 if height > 0 {
2484 result.action_rects[index] = Rect::new(area.x, y, area.width, height);
2485 }
2486 }
2487 result.controls = controls;
2488 result
2489 }
2490
2491 /// Paint and return the exact interactive contract for the visible buffer.
2492 pub fn render(&self, area: Rect, buf: &mut Buffer) -> DecisionBandPlan {
2493 let plan = self.plan(area.intersection(buf.area));
2494 plan.paint(buf);
2495 plan
2496 }
2497 }
2498
2499 impl DecisionBandPlan {
2500 fn paint(&self, buf: &mut Buffer) {
2501 use ratatui::widgets::{Block, Clear, Paragraph, Widget, Wrap};
2502 if self.region.is_empty() {
2503 return;
2504 }
2505 Clear.render(self.region, buf);
2506 if let Some(line) = &self.collapsed {
2507 Paragraph::new(line.clone()).render(self.region, buf);
2508 return;
2509 }
2510 Block::default()
2511 .style(self.background)
2512 .render(self.region, buf);
2513 let rule = self.rule.content.repeat(usize::from(self.region.width));
2514 buf.set_stringn(
2515 self.region.x,
2516 self.region.y,
2517 rule,
2518 usize::from(self.region.width),
2519 self.rule.style,
2520 );
2521 if self.head_truncated {
2522 let head_height = self.body_rect.height.saturating_sub(self.save_rect.height);
2523 if head_height > 0 {
2524 let shown = head_height.saturating_sub(1);
2525 if shown > 0 {
2526 Paragraph::new(self.head.clone())
2527 .wrap(Wrap { trim: false })
2528 .render(
2529 Rect {
2530 height: shown,
2531 ..self.body_rect
2532 },
2533 buf,
2534 );
2535 }
2536 buf.set_span(
2537 self.region.x,
2538 self.body_rect.y.saturating_add(shown),
2539 &self.hint,
2540 self.region.width,
2541 );
2542 }
2543 if self.save_rect.height > 0 {
2544 Paragraph::new(self.save.clone())
2545 .wrap(Wrap { trim: false })
2546 .render(self.save_rect, buf);
2547 }
2548 } else {
2549 Paragraph::new(self.head.clone())
2550 .wrap(Wrap { trim: false })
2551 .render(self.body_rect, buf);
2552 }
2553 Paragraph::new(self.controls.clone())
2554 .wrap(Wrap { trim: false })
2555 .render(self.control_rect, buf);
2556 }
2557 }
2558
2559 fn band_region(
2560 area: Rect,
2561 body: &[Line<'static>],
2562 save_rows: u16,
2563 controls: &[Line<'static>],
2564 ) -> Rect {
2565 if area.is_empty() {
2566 return Rect::new(area.x, area.bottom(), 0, 0);
2567 }
2568 let body_rows = decision_wrapped_rows(body, area.width);
2569 let control_rows = decision_wrapped_rows(controls, area.width);
2570 let desired = 1u16.saturating_add(body_rows).saturating_add(control_rows);
2571 let controls_floor = 1u16.saturating_add(control_rows).min(area.height);
2572 let head_rows = body_rows.saturating_sub(save_rows);
2573 let preview_rows = if area.height >= 16 {
2574 head_rows.min(4).saturating_add(save_rows)
2575 } else {
2576 save_rows
2577 };
2578 let preview_floor = controls_floor.saturating_add(preview_rows).min(area.height);
2579 let preferred_cap = area.height.div_ceil(2);
2580 let short_cap = area.height.saturating_mul(4).div_ceil(5);
2581 let save_floor = controls_floor.saturating_add(save_rows).min(area.height);
2582 let max_height = preferred_cap
2583 .max(preview_floor.min(short_cap.saturating_add(save_rows)))
2584 .max(save_floor)
2585 .min(area.height);
2586 let height = desired.clamp(controls_floor, max_height);
2587 Rect::new(
2588 area.x,
2589 area.y.saturating_add(area.height.saturating_sub(height)),
2590 area.width,
2591 height,
2592 )
2593 }
2594
2595 impl Paint for DecisionBand {
2596 fn paint(&self, area: Rect, buf: &mut Buffer, _theme: &Theme) {
2597 self.render(area, buf);
2598 }
2599 fn height(&self, width: u16, _theme: &Theme) -> u16 {
2600 self.plan(Rect::new(0, 0, width, u16::MAX)).region.height
2601 }
2602 }
2603
2603 lines RUST