返回 CodeWhale
attention.rs
1 //! A quiet inbox for work that needs a person's answer.
2 //!
3 //! Hosts supply every request, identity, state, priority, timestamp and action.
4 //! Priority changes display order only. Selection refers to the original input
5 //! index, so sorting never changes which request a host means to open. This
6 //! component neither grants permission nor dispatches an action.
7
8 use std::{borrow::Cow, cmp::Reverse};
9
10 use ratatui::{
11 buffer::Buffer,
12 layout::Rect,
13 style::Modifier,
14 text::{Line, Span},
15 widgets::{Clear, Widget},
16 };
17 use unicode_segmentation::UnicodeSegmentation;
18
19 use crate::{EmptyState, KeyHints, ListState, Paint, Role, State, StatusMark, Theme, glyphs, text};
20
21 use super::workbench::row;
22
23 /// Localized inbox copy. States use the item's [`StatusMark`] word.
24 #[derive(Clone, Debug, PartialEq, Eq)]
25 pub struct AttentionWords {
26 pub title: Cow<'static, str>,
27 /// Shown after the number of caller-supplied items.
28 pub requests: Cow<'static, str>,
29 /// Shown after the count, including partially visible requests.
30 pub hidden: Cow<'static, str>,
31 pub clipped: Cow<'static, str>,
32 pub empty_title: Cow<'static, str>,
33 pub empty_detail: Cow<'static, str>,
34 }
35
36 const ENGLISH: AttentionWords = AttentionWords {
37 title: Cow::Borrowed("Needs you"),
38 requests: Cow::Borrowed("waiting"),
39 hidden: Cow::Borrowed("not fully shown"),
40 clipped: Cow::Borrowed("Open the full request before answering"),
41 empty_title: Cow::Borrowed("No requests waiting"),
42 empty_detail: Cow::Borrowed("Questions and decisions appear here."),
43 };
44
45 impl Default for AttentionWords {
46 fn default() -> Self {
47 ENGLISH.clone()
48 }
49 }
50
51 /// A request with project and agent context. Its action is display copy;
52 /// the host owns the underlying decision and all keyboard/pointer handlers.
53 #[derive(Clone, Debug)]
54 pub struct AttentionItem<'a> {
55 pub project: Cow<'a, str>,
56 pub agent: Cow<'a, str>,
57 pub request: Cow<'a, str>,
58 pub status: StatusMark,
59 /// Greater values appear first; equal priorities preserve caller order.
60 pub priority: u8,
61 pub timestamp: Option<Cow<'a, str>>,
62 pub action: Option<Cow<'a, str>>,
63 /// Shortcuts appear only on the selected request, alongside its action.
64 pub hints: Option<&'a KeyHints>,
65 pub selected: bool,
66 pub focused: bool,
67 /// Keep the inbox concise. Further request rows get an explicit cue,
68 /// and the action rail is withheld until the full request is available.
69 pub request_rows: u16,
70 pub words: Option<&'a AttentionWords>,
71 }
72
73 impl<'a> AttentionItem<'a> {
74 #[must_use]
75 pub fn new(
76 project: impl Into<Cow<'a, str>>,
77 agent: impl Into<Cow<'a, str>>,
78 request: impl Into<Cow<'a, str>>,
79 state: State,
80 ) -> Self {
81 Self {
82 project: project.into(),
83 agent: agent.into(),
84 request: request.into(),
85 status: StatusMark::new(state),
86 priority: 0,
87 timestamp: None,
88 action: None,
89 hints: None,
90 selected: false,
91 focused: false,
92 request_rows: 3,
93 words: None,
94 }
95 }
96
97 #[must_use]
98 pub fn priority(mut self, priority: u8) -> Self {
99 self.priority = priority;
100 self
101 }
102
103 #[must_use]
104 pub fn timestamp(mut self, timestamp: impl Into<Cow<'a, str>>) -> Self {
105 self.timestamp = Some(timestamp.into());
106 self
107 }
108
109 #[must_use]
110 pub fn action(mut self, action: impl Into<Cow<'a, str>>) -> Self {
111 self.action = Some(action.into());
112 self
113 }
114
115 #[must_use]
116 pub fn hints(mut self, hints: &'a KeyHints) -> Self {
117 self.hints = Some(hints);
118 self
119 }
120
121 #[must_use]
122 pub fn selected(mut self, selected: bool) -> Self {
123 self.selected = selected;
124 self
125 }
126
127 #[must_use]
128 pub fn focused(mut self, focused: bool) -> Self {
129 self.focused = focused;
130 self
131 }
132
133 #[must_use]
134 pub fn request_rows(mut self, rows: u16) -> Self {
135 self.request_rows = rows.max(1);
136 self
137 }
138
139 #[must_use]
140 pub fn status_word(mut self, word: impl Into<Cow<'static, str>>) -> Self {
141 self.status = self.status.word(word);
142 self
143 }
144
145 #[must_use]
146 pub fn with_words(mut self, words: &'a AttentionWords) -> Self {
147 self.words = Some(words);
148 self
149 }
150
151 fn words(&self) -> &AttentionWords {
152 self.words.unwrap_or(&ENGLISH)
153 }
154
155 fn plan(&self, width: u16, theme: &Theme, selected: bool) -> Vec<Line<'static>> {
156 if width == 0 {
157 return Vec::new();
158 }
159 let content_width = width.saturating_sub(gutter(width));
160 let project = safe(&self.project);
161 let agent = safe(&self.agent);
162 let identity = if agent.is_empty() {
163 project
164 } else {
165 format!("{project} / {agent}")
166 };
167 let timestamp = self
168 .timestamp
169 .as_deref()
170 .map(safe)
171 .filter(|value| !value.is_empty());
172 let mut state = self.status.spans(theme);
173 let status_width: usize = state.iter().map(Span::width).sum();
174 if let Some(timestamp) = timestamp {
175 state.push(Span::raw(" "));
176 state.push(Span::styled(timestamp, theme.fg(Role::Muted)));
177 }
178 let state_width: usize = state.iter().map(Span::width).sum();
179 let mut complete = status_width <= usize::from(content_width);
180 let mut lines = Vec::new();
181 let strong = theme.fg(Role::Foreground).add_modifier(Modifier::BOLD);
182 if content_width >= 52 && state_width + 4 < usize::from(content_width) {
183 let identity_width = usize::from(content_width).saturating_sub(state_width + 2);
184 let identity_fit = text::truncate(&identity, identity_width, theme.ascii());
185 complete &= identity_fit == identity;
186 let used = text::width(&identity_fit);
187 let mut header = vec![
188 Span::styled(identity_fit.into_owned(), strong),
189 Span::raw(
190 " ".repeat(usize::from(content_width).saturating_sub(used + state_width)),
191 ),
192 ];
193 header.extend(state);
194 lines.push(Line::from(header));
195 } else {
196 let status = Line::from(state);
197 // Metadata can recede at narrow widths, but an identity that is
198 // clipped must never sit above an available decision shortcut.
199 lines.push(status);
200 let identity_fit = text::truncate(&identity, usize::from(content_width), theme.ascii());
201 complete &= identity_fit == identity;
202 lines.push(Line::from(Span::styled(identity_fit.into_owned(), strong)));
203 }
204 let request = wrap(&self.request, content_width, theme.ascii());
205 let keep = usize::from(self.request_rows.max(1));
206 complete &= request.complete && request.lines.len() <= keep;
207 lines.extend(
208 request
209 .lines
210 .into_iter()
211 .take(keep)
212 .map(|line| Line::from(Span::styled(line, theme.fg(Role::Foreground)))),
213 );
214 if complete {
215 let mut actions = Vec::new();
216 if let Some(action) = self
217 .action
218 .as_deref()
219 .map(safe)
220 .filter(|value| !value.is_empty())
221 {
222 actions.extend(
223 wrap(&action, content_width, theme.ascii())
224 .lines
225 .into_iter()
226 .map(|line| {
227 Line::from(Span::styled(
228 line,
229 theme.fg(if selected { Role::Primary } else { Role::Muted }),
230 ))
231 }),
232 );
233 }
234 if selected && let Some(hints) = self.hints {
235 let hint_lines = hints.lines(content_width, theme);
236 if actions.len() == 1
237 && hint_lines.len() == 1
238 && actions[0].width() + hint_lines[0].width() + 3 <= usize::from(content_width)
239 {
240 let gap = usize::from(content_width)
241 .saturating_sub(actions[0].width() + hint_lines[0].width());
242 actions[0].spans.push(Span::raw(" ".repeat(gap)));
243 actions[0].spans.extend(hint_lines[0].spans.clone());
244 } else {
245 actions.extend(hint_lines);
246 }
247 }
248 lines.extend(actions);
249 } else {
250 lines.push(clipped_line(self.words(), content_width, theme));
251 }
252 lines
253 }
254
255 fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme, selected: bool, focused: bool) {
256 if area.is_empty() {
257 return;
258 }
259 Clear.render(area, buf);
260 buf.set_style(
261 area,
262 theme.bg(if selected {
263 Role::Selected
264 } else {
265 Role::Background
266 }),
267 );
268 let gutter = gutter(area.width);
269 let content = Rect {
270 x: area.x.saturating_add(gutter),
271 width: area.width.saturating_sub(gutter),
272 ..area
273 };
274 let mut lines = self.plan(area.width, theme, selected);
275 if lines.len() > usize::from(area.height) {
276 if area.height == 1 {
277 lines.truncate(1);
278 } else {
279 lines.truncate(usize::from(area.height.saturating_sub(1)));
280 lines.push(clipped_line(self.words(), content.width, theme));
281 }
282 }
283 for (index, line) in lines.iter().take(usize::from(area.height)).enumerate() {
284 let y = area.y.saturating_add(rows(index));
285 if gutter > 0 {
286 let mark = if index == 0 && selected {
287 glyphs::SELECTION
288 } else {
289 "▏"
290 };
291 let rail = if selected && focused {
292 Role::Primary
293 } else {
294 Role::BorderStrong
295 };
296 buf.set_stringn(
297 area.x,
298 y,
299 glyphs::pick(mark, theme.ascii()),
300 1,
301 theme.fg(rail),
302 );
303 }
304 row(
305 Rect {
306 y,
307 height: 1,
308 ..content
309 },
310 buf,
311 line,
312 );
313 }
314 }
315 }
316
317 impl Paint for AttentionItem<'_> {
318 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
319 self.draw(
320 area.intersection(buf.area),
321 buf,
322 theme,
323 self.selected,
324 self.focused,
325 );
326 }
327
328 fn height(&self, width: u16, theme: &Theme) -> u16 {
329 rows(self.plan(width, theme, self.selected).len())
330 }
331 }
332
333 /// An inbox with stable caller priority, original-index selection and an
334 /// honest overflow rail. Hosts own navigation, ordering inputs and opening.
335 #[derive(Clone, Debug)]
336 pub struct AttentionQueue<'a> {
337 pub items: &'a [AttentionItem<'a>],
338 /// Original index in `items`, never an index in the sorted presentation.
339 pub selected: Option<usize>,
340 pub focused: bool,
341 /// Preferred offset in priority order; selection remains in view.
342 pub offset: usize,
343 pub words: Option<&'a AttentionWords>,
344 }
345
346 impl<'a> AttentionQueue<'a> {
347 #[must_use]
348 pub fn new(items: &'a [AttentionItem<'a>]) -> Self {
349 Self {
350 items,
351 selected: None,
352 focused: false,
353 offset: 0,
354 words: None,
355 }
356 }
357
358 #[must_use]
359 pub fn selected(mut self, index: usize) -> Self {
360 self.selected = Some(index);
361 self
362 }
363
364 #[must_use]
365 pub fn focused(mut self, focused: bool) -> Self {
366 self.focused = focused;
367 self
368 }
369
370 #[must_use]
371 pub fn offset(mut self, offset: usize) -> Self {
372 self.offset = offset;
373 self
374 }
375
376 #[must_use]
377 pub fn with_words(mut self, words: &'a AttentionWords) -> Self {
378 self.words = Some(words);
379 self
380 }
381
382 /// Original item indices in display order. Priority is a presentation
383 /// input, with no urgency or lifecycle inferred from state or age.
384 #[must_use]
385 pub fn order(&self) -> Vec<usize> {
386 let mut order: Vec<_> = (0..self.items.len()).collect();
387 order.sort_by_key(|index| (Reverse(self.items[*index].priority), *index));
388 order
389 }
390
391 fn words(&self) -> &AttentionWords {
392 self.words.unwrap_or(&ENGLISH)
393 }
394 }
395
396 impl Paint for AttentionQueue<'_> {
397 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
398 let area = area.intersection(buf.area);
399 if area.is_empty() {
400 return;
401 }
402 Clear.render(area, buf);
403 buf.set_style(area, theme.bg(Role::Background));
404 let words = self.words();
405 paint_heading(
406 words,
407 self.items.len(),
408 Rect { height: 1, ..area },
409 buf,
410 theme,
411 );
412 if area.height < 3 {
413 return;
414 }
415 for x in area.x..area.right() {
416 buf[(x, area.y.saturating_add(1))]
417 .set_symbol(glyphs::pick("─", theme.ascii()))
418 .set_style(theme.fg(Role::Border));
419 }
420 let body = Rect {
421 y: area.y.saturating_add(2),
422 height: area.height.saturating_sub(2),
423 ..area
424 };
425 if self.items.is_empty() {
426 paint_empty(words, body, buf, theme);
427 return;
428 }
429 let order = self.order();
430 let selected = self.selected.filter(|index| *index < self.items.len());
431 let heights: Vec<_> = order
432 .iter()
433 .map(|index| {
434 rows(
435 self.items[*index]
436 .plan(body.width, theme, selected == Some(*index))
437 .len(),
438 )
439 })
440 .collect();
441 let total = heights
442 .iter()
443 .map(|height| usize::from(*height))
444 .sum::<usize>()
445 .saturating_add(order.len().saturating_sub(1));
446 let clipped = total > usize::from(body.height) || self.offset > 0;
447 let available = body.height.saturating_sub(u16::from(clipped));
448 let rank = selected.and_then(|selected| order.iter().position(|index| *index == selected));
449 let offset = if let Some(rank) = rank {
450 ListState {
451 selected: rank,
452 offset: self.offset,
453 }
454 .visible_offset(order.len(), available, |index| {
455 heights[index].saturating_add(u16::from(index + 1 < order.len()))
456 })
457 } else {
458 self.offset.min(order.len().saturating_sub(1))
459 };
460 let mut used = 0u16;
461 let mut complete = 0usize;
462 for rank in offset..order.len() {
463 if used >= available {
464 break;
465 }
466 let index = order[rank];
467 let height = heights[rank].min(available.saturating_sub(used));
468 self.items[index].draw(
469 Rect {
470 y: body.y.saturating_add(used),
471 height,
472 ..body
473 },
474 buf,
475 theme,
476 selected == Some(index),
477 self.focused,
478 );
479 complete += usize::from(height == heights[rank]);
480 used = used.saturating_add(height).saturating_add(1);
481 }
482 if clipped && complete < self.items.len() {
483 let hidden = self.items.len().saturating_sub(complete);
484 row(
485 Rect {
486 y: body.bottom().saturating_sub(1),
487 height: 1,
488 ..body
489 },
490 buf,
491 &Line::from(Span::styled(
492 format!("{hidden} {}", safe(&words.hidden)),
493 theme.fg(Role::Muted),
494 )),
495 );
496 }
497 }
498
499 fn height(&self, width: u16, theme: &Theme) -> u16 {
500 if width == 0 {
501 return 0;
502 }
503 if self.items.is_empty() {
504 return 5;
505 }
506 let content = self
507 .items
508 .iter()
509 .enumerate()
510 .map(|(index, item)| item.plan(width, theme, self.selected == Some(index)).len())
511 .sum::<usize>();
512 rows(
513 content
514 .saturating_add(self.items.len().saturating_sub(1))
515 .saturating_add(2),
516 )
517 }
518 }
519
520 // Heading's fit is private; keep its title priority and quiet meta, using
521 // the shared buffer-row writer even at the last representable coordinate.
522 fn paint_heading(
523 words: &AttentionWords,
524 count: usize,
525 area: Rect,
526 buf: &mut Buffer,
527 theme: &Theme,
528 ) {
529 let width = usize::from(area.width);
530 let title = safe(&words.title);
531 let meta = format!("{count} {}", safe(&words.requests));
532 let full = text::width(&title)
533 .saturating_add(text::width(&meta))
534 .saturating_add(2)
535 <= width;
536 let budget = if full {
537 text::width(&meta)
538 } else if width >= 12 {
539 text::width(&meta).min(width / 3)
540 } else {
541 0
542 };
543 let meta = if full || budget >= 4 {
544 text::truncate(&meta, budget, theme.ascii()).into_owned()
545 } else {
546 String::new()
547 };
548 let room = width.saturating_sub(if meta.is_empty() {
549 0
550 } else {
551 text::width(&meta) + 2
552 });
553 let title = text::truncate(&title, room, theme.ascii()).into_owned();
554 let gap = width.saturating_sub(text::width(&title) + text::width(&meta));
555 row(
556 area,
557 buf,
558 &Line::from(vec![
559 Span::styled(
560 title,
561 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
562 ),
563 Span::raw(" ".repeat(if meta.is_empty() { 0 } else { gap })),
564 Span::styled(meta, theme.fg(Role::Muted)),
565 ]),
566 );
567 }
568
569 fn paint_empty(words: &AttentionWords, area: Rect, buf: &mut Buffer, theme: &Theme) {
570 if area.right() != u16::MAX && area.bottom() != u16::MAX {
571 EmptyState::new(words.empty_title.clone())
572 .body(words.empty_detail.clone())
573 .paint(area, buf, theme);
574 return;
575 }
576 // The general empty component uses centered terminal widgets. At the
577 // saturated edge, retain its title/body hierarchy with safe row writes.
578 let title = text::truncate(
579 &safe(&words.empty_title),
580 usize::from(area.width),
581 theme.ascii(),
582 )
583 .into_owned();
584 let body = wrap(&words.empty_detail, area.width, theme.ascii());
585 let mut lines = vec![Line::styled(
586 title,
587 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
588 )];
589 lines.extend(
590 body.lines
591 .into_iter()
592 .take(3)
593 .map(|line| Line::styled(line, theme.fg(Role::Muted))),
594 );
595 lines.truncate(usize::from(area.height));
596 let top = area
597 .y
598 .saturating_add(area.height.saturating_sub(rows(lines.len())) / 2);
599 for (index, mut line) in lines.into_iter().enumerate() {
600 let inset = usize::from(area.width).saturating_sub(line.width()) / 2;
601 line.spans.insert(0, Span::raw(" ".repeat(inset)));
602 row(
603 Rect {
604 y: top.saturating_add(rows(index)),
605 height: 1,
606 ..area
607 },
608 buf,
609 &line,
610 );
611 }
612 }
613
614 fn safe(value: &str) -> String {
615 text::display_safe(value).into_owned()
616 }
617 fn rows(count: usize) -> u16 {
618 u16::try_from(count).unwrap_or(u16::MAX)
619 }
620 fn gutter(width: u16) -> u16 {
621 if width >= 6 { 2 } else { 0 }
622 }
623
624 fn clipped_line(words: &AttentionWords, width: u16, theme: &Theme) -> Line<'static> {
625 let words = safe(&words.clipped);
626 Line::from(Span::styled(
627 text::truncate_words(&words, usize::from(width), theme.ascii()).into_owned(),
628 theme.fg(Role::Attention),
629 ))
630 }
631
632 // Keep authored line breaks and word spacing; long words break only between
633 // whole Unicode graphemes. A grapheme wider than the viewport gets a cut mark.
634 struct Wrapped {
635 lines: Vec<String>,
636 complete: bool,
637 }
638
639 fn wrap(value: &str, width: u16, ascii: bool) -> Wrapped {
640 let mut result = Wrapped {
641 lines: Vec::new(),
642 complete: true,
643 };
644 if value.is_empty() || width == 0 {
645 return result;
646 }
647 let width = usize::from(width);
648 for logical in value.split('\n') {
649 let logical = safe(logical);
650 let graphemes: Vec<_> = logical
651 .graphemes(true)
652 .map(|value| {
653 if text::width(value) > width {
654 result.complete = false;
655 Cow::Owned(text::truncate(value, width, ascii).into_owned())
656 } else {
657 Cow::Borrowed(value)
658 }
659 })
660 .collect();
661 if graphemes.is_empty() {
662 result.lines.push(String::new());
663 }
664 let mut start = 0;
665 while start < graphemes.len() {
666 let mut end = start;
667 let mut cells = 0;
668 let mut boundary = None;
669 while end < graphemes.len() && cells + text::width(&graphemes[end]) <= width {
670 cells += text::width(&graphemes[end]);
671 if graphemes[end].chars().all(char::is_whitespace) {
672 boundary = Some(end + 1);
673 }
674 end += 1;
675 }
676 if end < graphemes.len()
677 && let Some(boundary) = boundary
678 {
679 end = boundary;
680 }
681 result.lines.push(
682 graphemes[start..end]
683 .iter()
684 .map(|value| value.as_ref())
685 .collect(),
686 );
687 start = end;
688 }
689 }
690 result
691 }
692
692 lines RUST