Skip to main content

tix/ui/
triage.rs

1//! The triage inbox and its decision modal (WP06, T029–T032).
2//!
3//! One view over the queue the WP02 service answers, plus the modal that
4//! decides an item of it. The view is a **modal over the board**, exactly like
5//! settings, planning and the batch modal, so nothing about the board, the
6//! tabs or the card cursor changes to make room for it: the inbox renders on
7//! top and `esc` drops it, leaving the cursor where it was (T029).
8//!
9//! ## What this module computes, and what it does not
10//!
11//! Nothing here decides anything. Age, membership, counts and the histogram
12//! come from [`crate::triage::inbox::scope`] plus
13//! [`crate::triage::inbox::collect`] — one pass over one snapshot. A
14//! decision is [`crate::triage::batch::build_plan`] plus, only after a second
15//! `y`, [`crate::triage::batch::apply`], which is the same #173 core
16//! `tix triage batch` drives (C-005, T031). The preview and report text is the
17//! CLI's own [`crate::commands::triage`] renderers, so the two surfaces cannot
18//! describe the same plan differently (NFR-003).
19//!
20//! There is no second completion rule, no status compared against a literal,
21//! and no `store.update` outside the batch engine's. The one thing that *is*
22//! restated here is the shape of a refusal when there is no inbox at all —
23//! `TriageState::open` needs a message, and `commands::triage::scope_of` is
24//! private to that module; both are `TixError::InvalidInput` and both name
25//! `[triage]` in `tix.toml`, which is the part the contract makes load-bearing.
26//!
27//! ## Why the decision keys are not in the key table
28//!
29//! `a`, `d`, `u`, `f`, `p`, `y`, `q` and every letter a reason or a duplicate
30//! target might contain are deliberately **unbound** in
31//! [`crate::keybindings::InputContext::Triage`], so
32//! [`crate::app::App::handle_key`]'s fallback hands them to the handler
33//! verbatim. Binding `q` to close the inbox — which the board does — would
34//! canonicalise a `q` typed into a decline reason into an `Esc` and close the
35//! modal mid-sentence; the same defect is why `LinkPrompt` and `Comment` bind
36//! only `esc`. What the modal can do at any moment is named by its own
37//! [`TriageModal::footer`], which is how the batch modal names its keys.
38
39use std::fmt::Write as _;
40
41use ratatui::Frame;
42use ratatui::layout::{Constraint, Direction, Layout, Rect};
43use ratatui::style::{Color, Modifier, Style};
44use ratatui::text::{Line, Span};
45use ratatui::widgets::{Block, Borders, Clear, Paragraph};
46use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
47
48use crate::app::{Action, App};
49use crate::bulk::{Selection, SelectionScope, SelectionSource};
50use crate::commands::triage as cli_triage;
51use crate::error::{Result, TixError};
52use crate::model::Issue;
53use crate::storage::Store;
54use crate::triage::inbox::{self, AgeBucket, InboxScope, QueueMetrics};
55use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch};
56use crate::ui::bulk::BulkSelection;
57use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem};
58
59// ---------------------------------------------------------------------------
60// T029/T030 — the queue snapshot
61// ---------------------------------------------------------------------------
62
63/// One row of the inbox: the issue, how long it has waited, and the last
64/// decision recorded on it.
65///
66/// Owned rather than borrowed because [`inbox::QueueMetrics`] borrows the
67/// snapshot it was collected from, and the inbox outlives the query that
68/// filled it.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct QueueRow {
71    /// The issue as the snapshot read it.
72    pub issue: Issue,
73    /// Days since the entry stamp; `None` when nothing recorded one. Never
74    /// `0` for "unknown" — that is [`inbox`]'s rule and it is copied, not
75    /// re-derived.
76    pub age_days: Option<i64>,
77    /// The last triage decision, told apart from none and from a line that no
78    /// longer parses (tix #1494). A reader that shows the queue wants all three
79    /// states; `None` used to mean both "undecided" and "unreadable".
80    pub last_decision: crate::triage::history::LastDecision,
81}
82
83/// The counts and histogram the header renders, copied out of [`QueueMetrics`]
84/// so the snapshot outlives the query that produced it.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct TriageSummary {
87    /// Inbox entries.
88    pub total: usize,
89    /// Entries in the entry column.
90    pub awaiting: usize,
91    /// Entries in the deferred column.
92    pub deferred: usize,
93    /// Entries already `completed` while still in an inbox column.
94    pub accepted: usize,
95    /// Entries already `canceled` while still in an inbox column.
96    pub declined: usize,
97    /// Entries that originate a `duplicates` edge.
98    pub duplicated: usize,
99    /// Entries with no readable entry stamp.
100    pub unknown_age: usize,
101    /// The age histogram, in [`inbox::AGE_BUCKETS`] order. Sums to `total`.
102    pub age: Vec<AgeBucket>,
103}
104
105impl TriageSummary {
106    /// Copy the metrics out of the query that borrowed them.
107    #[must_use]
108    pub fn of(metrics: &QueueMetrics<'_>) -> Self {
109        Self {
110            total: metrics.total,
111            awaiting: metrics.awaiting,
112            deferred: metrics.deferred,
113            accepted: metrics.accepted,
114            declined: metrics.declined,
115            duplicated: metrics.duplicated,
116            unknown_age: metrics.unknown_age,
117            age: metrics.age_distribution.clone(),
118        }
119    }
120
121    /// The header line, in the **CLI's** wording.
122    ///
123    /// `commands::triage::summary_line` prints this same string for
124    /// `tix triage list --count`, and
125    /// `triage_tui_the_inbox_counts_are_the_clis_counts_for_the_same_snapshot`
126    /// pins the two together: a header the two surfaces could spell differently
127    /// is a header a reader would trust differently.
128    #[must_use]
129    pub fn header(&self) -> String {
130        format!(
131            "inbox: {} entr{} ({} awaiting, {} deferred)",
132            self.total,
133            if self.total == 1 { "y" } else { "ies" },
134            self.awaiting,
135            self.deferred
136        )
137    }
138
139    /// The age histogram, every bucket named including the zeros.
140    #[must_use]
141    pub fn age_line(&self) -> String {
142        let cells: Vec<String> = self
143            .age
144            .iter()
145            .map(|bucket| format!("{} {}", bucket.label, bucket.count))
146            .collect();
147        format!("age: {}", cells.join(" · "))
148    }
149
150    /// The decision counts. `accepted`/`declined` are the WP02 *staleness*
151    /// readings — they come from `is_completed` and the category resolver, not
152    /// from a column name.
153    #[must_use]
154    pub fn decisions_line(&self) -> String {
155        format!(
156            "decisions: {} accepted · {} declined · {} duplicated · {} unknown age",
157            self.accepted, self.declined, self.duplicated, self.unknown_age
158        )
159    }
160}
161
162/// The one row under the cursor, for the detail line below the list.
163#[must_use]
164pub fn decision_summary(row: &QueueRow) -> String {
165    let mut out = format!(
166        "#{number} \"{title}\" — {status}",
167        number = row.issue.number,
168        title = row.issue.title,
169        status = row.issue.status.display(),
170    );
171    match &row.last_decision {
172        crate::triage::history::LastDecision::Readable(record) => {
173            let _ = write!(out, " — last decision: {}", format_decision(record));
174        }
175        crate::triage::history::LastDecision::Unreadable => {
176            out.push_str(" — last decision: unreadable");
177        }
178        crate::triage::history::LastDecision::None => {
179            out.push_str(" — last decision: none");
180        }
181    }
182    out
183}
184
185/// The last decision on `issue`, told apart from none and unreadable (tix
186/// #1494).
187///
188/// The display half of the triage History seam: an issue whose newest triage
189/// line exists but no longer parses must read as *unreadable*, not as
190/// *undecided*, so the maintainer sees the file that needs attention.
191fn last_decision(issue: &Issue) -> crate::triage::history::LastDecision {
192    crate::triage::history::last_decision_state(issue)
193}
194
195/// Format a parsed decision the way a queue row shows it.
196///
197/// `triage_decided` and friends rather than the decision verb: the History
198/// event's own kind is what is on disk, and a summary that renamed it would be
199/// a second spelling of the same fact.
200fn format_decision(record: &crate::triage::history::TriageDecisionRecord) -> String {
201    let mut out = format!("{} -> {}", record.kind.as_str(), record.result);
202    if let Some(reason) = &record.reason {
203        let _ = write!(out, " — {reason}");
204    }
205    let when = record.timestamp.get(..10).unwrap_or(&record.timestamp);
206    let _ = write!(out, " ({}, {})", record.actor, when);
207    out
208}
209
210/// The inbox scope, or the one refusal every triage surface shares.
211///
212/// The message names the *inbox* rather than `tix triage`, because this is the
213/// TUI. What the contract makes load-bearing is the category and the fact that
214/// both name `[triage]` in `tix.toml`, and both are the crate's existing
215/// `invalid input` variant — no triage diagnostic in
216/// `crate::workflow::DiagnosticKind` (whose `ALL` stays at seven).
217fn scope_of(store: &Store) -> Result<InboxScope> {
218    inbox::scope(&store.config)?.ok_or_else(|| {
219        TixError::InvalidInput(
220            "the triage inbox needs a `[triage]` section in tix.toml, and this project has none"
221                .to_string(),
222        )
223    })
224}
225
226// ---------------------------------------------------------------------------
227// T031 — the decision modal
228// ---------------------------------------------------------------------------
229
230/// What the modal is asking for.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum DecisionField {
233    Reason,
234    Actor,
235    /// The accept target column (`--to`). Present for `accept` only, so `--to` on a
236    /// decline cannot be typed — the same check `run_batch` makes on a flag.
237    Target,
238    /// Who should own the issue after an accept. Blank leaves it unchanged.
239    Assignee,
240    /// The duplicate target (`--of`). Present for `duplicate` only, so `--of`
241    /// on an accept cannot be typed either.
242    DuplicateOf,
243}
244
245impl DecisionField {
246    #[must_use]
247    pub fn label(self, kind: DecisionKind) -> &'static str {
248        match (kind, self) {
249            (DecisionKind::Accept, Self::Target) => "column (required)",
250            (DecisionKind::Accept, Self::Actor) => "actor (required)",
251            (DecisionKind::Accept, Self::Reason) => "reason (optional)",
252            (DecisionKind::Accept, Self::Assignee) => "assignee (optional)",
253            (_, Self::Reason) => "reason (required)",
254            (_, Self::Actor) => "actor (required)",
255            (DecisionKind::Duplicate, Self::DuplicateOf) => "duplicate of (required)",
256            (_, Self::Target) => "column (required)",
257            (_, Self::Assignee) => "assignee (optional)",
258            (_, Self::DuplicateOf) => "duplicate of (required)",
259        }
260    }
261}
262
263/// The issue a decision is about: one from the cursor, or everything marked.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum ModalScope {
266    /// One issue, by number. Resolved from the **store** at plan time, so the
267    /// plan is built from the file on disk and not from a row the list drew
268    /// before the file changed.
269    One(u32),
270    /// The marked issues, in the order they were marked.
271    Many(BulkSelection),
272}
273
274/// Where the modal is in its own cycle. The #173 batch modal's shape, because
275/// "I saw it" and "I approved it" are two decisions here too.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum ModalState {
278    /// The fields, ready to edit.
279    Editing,
280    /// The modal asked for something it cannot plan without.
281    NeedsInput(String),
282    /// A plan is on screen. **Nothing has been written.**
283    Preview(Box<TriagePlan>),
284    /// The plan is armed: one more `y` writes.
285    Confirming(Box<TriagePlan>),
286    /// The apply ran. Its per-item outcomes and counts are in the string.
287    Done(String),
288    /// The plan or the apply was refused. Its reasons are in the string.
289    Refused(String),
290}
291
292/// The decision modal: four fields, four states, one plan.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct TriageModal {
295    /// Which decision.
296    pub kind: DecisionKind,
297    /// One issue or the marked set.
298    pub scope: ModalScope,
299    /// What the title says: `#12 Alpha` or `3 marked issues`.
300    pub heading: String,
301    /// The fields this decision has, in the order they are drawn.
302    pub fields: Vec<DecisionField>,
303    /// Which field the cursor is on.
304    pub cursor: usize,
305    /// Whether the primary `OK` action, rather than a field row, owns the
306    /// keyboard focus. A modal opens here so Enter accepts the defaults.
307    pub ok_focused: bool,
308    /// Whether the cursor's field is being typed into.
309    pub editing: bool,
310    /// The text being typed. Discarded on `esc`, never committed.
311    pub draft: String,
312    pub reason: String,
313    pub actor: String,
314    pub target: String,
315    pub assignee: String,
316    pub duplicate_of: String,
317    /// The decision timestamp, fixed when the modal opened so a preview and the
318    /// apply it authorises stamp the same event.
319    pub timestamp: String,
320    pub state: ModalState,
321    /// The last message, for the status bar and the body.
322    pub note: Option<String>,
323    /// Whether the plan no longer matches the screen.
324    pub plan_stale: bool,
325}
326
327impl TriageModal {
328    /// A fresh modal for `kind` over `scope`.
329    ///
330    /// The fields are fixed per decision rather than per keystroke, which is
331    /// how `--to` on a decline and `--of` on an accept become *untypeable*
332    /// instead of merely refused: the flag does not exist to type into.
333    #[must_use]
334    pub fn new(kind: DecisionKind, scope: ModalScope, heading: String) -> Self {
335        let fields = Self::fields_for(kind);
336        Self {
337            kind,
338            scope,
339            heading,
340            fields,
341            cursor: 0,
342            ok_focused: true,
343            editing: false,
344            draft: String::new(),
345            reason: String::new(),
346            actor: String::new(),
347            target: String::new(),
348            assignee: String::new(),
349            duplicate_of: String::new(),
350            timestamp: crate::model::utc_now_rfc3339(),
351            state: ModalState::Editing,
352            note: None,
353            plan_stale: false,
354        }
355    }
356
357    #[must_use]
358    pub fn field(&self, index: usize) -> DecisionField {
359        self.fields
360            .get(index)
361            .copied()
362            .unwrap_or(DecisionField::Reason)
363    }
364
365    #[must_use]
366    pub fn field_value(&self, field: DecisionField) -> &str {
367        match field {
368            DecisionField::Reason => &self.reason,
369            DecisionField::Actor => &self.actor,
370            DecisionField::Target => &self.target,
371            DecisionField::Assignee => &self.assignee,
372            DecisionField::DuplicateOf => &self.duplicate_of,
373        }
374    }
375
376    fn field_mut(&mut self, field: DecisionField) -> &mut String {
377        match field {
378            DecisionField::Reason => &mut self.reason,
379            DecisionField::Actor => &mut self.actor,
380            DecisionField::Target => &mut self.target,
381            DecisionField::Assignee => &mut self.assignee,
382            DecisionField::DuplicateOf => &mut self.duplicate_of,
383        }
384    }
385
386    pub fn begin_edit(&mut self) {
387        let field = self.field(self.cursor);
388        self.draft = self.field_value(field).to_string();
389        self.ok_focused = false;
390        self.editing = true;
391    }
392
393    pub fn push_char(&mut self, c: char) {
394        if self.editing {
395            self.draft.push(c);
396        }
397    }
398
399    pub fn pop_char(&mut self) {
400        if self.editing {
401            self.draft.pop();
402        }
403    }
404
405    pub fn commit_edit(&mut self) {
406        if !self.editing {
407            return;
408        }
409        let field = self.field(self.cursor);
410        let draft = std::mem::take(&mut self.draft);
411        *self.field_mut(field) = draft;
412        self.editing = false;
413        // The row changed, so the plan on screen no longer describes it. The
414        // state is deliberately **not** reset to `Editing`: the plan is left
415        // visible and labelled stale, because a preview that silently vanished
416        // the moment a field was edited leaves the user with no way to see
417        // what their edit invalidated (contract §4: any change requires a new
418        // preview — and `confirm` is what refuses the old one).
419        self.plan_stale = true;
420    }
421
422    /// Abandon the draft. The row keeps whatever `commit_edit` last stored.
423    pub fn abort_edit(&mut self) {
424        self.draft.clear();
425        self.editing = false;
426    }
427
428    pub fn move_cursor(&mut self, delta: isize) {
429        if self.fields.is_empty() {
430            return;
431        }
432        self.ok_focused = false;
433        let len = self.fields.len() as isize;
434        let next = (self.cursor as isize + delta).rem_euclid(len);
435        self.cursor = next as usize;
436    }
437
438    /// Move keyboard focus through the fields and the primary `OK` action.
439    ///
440    /// `OK` is part of the same ring as the fields. Opening on `OK` makes Enter
441    /// accept the defaults, while Down/Tab enters the first field and
442    /// Up/BackTab enters the last.
443    pub fn move_focus(&mut self, delta: isize) {
444        if self.fields.is_empty() {
445            self.ok_focused = true;
446            return;
447        }
448        if self.ok_focused {
449            self.ok_focused = false;
450            self.cursor = if delta < 0 { self.fields.len() - 1 } else { 0 };
451            return;
452        }
453        let next = self.cursor as isize + delta;
454        if next < 0 || next >= self.fields.len() as isize {
455            self.ok_focused = true;
456        } else {
457            self.cursor = next as usize;
458        }
459    }
460
461    /// What the batch modal's `a` does: pick the next decision.
462    ///
463    /// The typed fields survive, so a reason the user already wrote does not
464    /// have to be written again because they changed their mind about which
465    /// decision it belonged to. What does **not** survive is any plan built
466    /// for the old verb — `state` goes back to [`ModalState::Editing`] and
467    /// `plan_stale` is set, so the only way to see a plan is to preview this
468    /// one (contract §4: any change requires a new preview).
469    pub fn cycle_kind(&mut self) {
470        self.kind = match self.kind {
471            DecisionKind::Accept => DecisionKind::Decline,
472            DecisionKind::Decline => DecisionKind::Duplicate,
473            DecisionKind::Duplicate => DecisionKind::Defer,
474            DecisionKind::Defer => DecisionKind::Accept,
475        };
476        self.fields = Self::fields_for(self.kind);
477        if self.cursor >= self.fields.len() {
478            self.cursor = self.fields.len() - 1;
479        }
480        self.state = ModalState::Editing;
481        self.plan_stale = true;
482    }
483
484    /// The rows this decision has, in the order they are drawn.
485    ///
486    /// Fixed per decision rather than per keystroke: `--to` on a decline and
487    /// `--of` on an accept do not exist as flags, so here they do not exist as
488    /// rows either, and the check `run_batch` makes on those flags is
489    /// unreachable by construction.
490    fn fields_for(kind: DecisionKind) -> Vec<DecisionField> {
491        match kind {
492            DecisionKind::Accept => vec![
493                DecisionField::Target,
494                DecisionField::Actor,
495                DecisionField::Reason,
496                DecisionField::Assignee,
497            ],
498            DecisionKind::Duplicate => vec![
499                DecisionField::DuplicateOf,
500                DecisionField::Reason,
501                DecisionField::Actor,
502            ],
503            DecisionKind::Decline | DecisionKind::Defer => {
504                vec![DecisionField::Reason, DecisionField::Actor]
505            }
506        }
507    }
508
509    /// Fill required values that have an unambiguous default.
510    ///
511    /// The accept column comes from `[triage].accepted_status`, which is `todo`
512    /// in the starter project, and falls back to `todo` when no status was
513    /// configured. The actor is the same identity the CLI would record when no
514    /// `--actor` was supplied.
515    pub fn prefill_defaults(&mut self, store: &Store, columns: &[String]) {
516        if self.kind == DecisionKind::Accept && self.target.trim().is_empty() {
517            if let Some(configured) = store
518                .config
519                .triage
520                .as_ref()
521                .and_then(|triage| triage.accepted_status.clone())
522                .filter(|target| !target.trim().is_empty())
523            {
524                self.target = configured;
525            } else if let Some(todo) = columns
526                .iter()
527                .find(|column| column.eq_ignore_ascii_case("todo"))
528            {
529                self.target.clone_from(todo);
530            }
531        }
532        if self.actor.trim().is_empty() {
533            self.actor = crate::storage::actor_identity();
534        }
535    }
536
537    /// Choose the accept destination from the target picker.
538    pub fn choose_target(&mut self, target: String) {
539        self.target = target;
540        self.plan_stale = true;
541    }
542
543    /// The explicit confirmation shown when one decision covers a marked set.
544    #[must_use]
545    pub fn confirmation_question(&self) -> Option<String> {
546        let ModalScope::Many(marked) = &self.scope else {
547            return None;
548        };
549        Some(format!(
550            "Are you sure you want to apply {} to {} selected issues?",
551            self.kind,
552            marked.len()
553        ))
554    }
555
556    /// The decision request these fields describe, shaped exactly like
557    /// `commands::triage::run_batch`'s: a whitespace-only target is no target,
558    /// and an accept that named none of its own names the configured one so the
559    /// preview and the service cannot propose two destinations.
560    ///
561    /// A blank actor means *not specified*, so it falls back to the identity
562    /// the CLI also uses when `--actor` is absent — an audit entry with no
563    /// actor is worse than no entry (contract §Required reason and actor).
564    #[must_use]
565    pub fn request(&self, store: &Store) -> TriageDecision {
566        let actor = if self.actor.trim().is_empty() {
567            crate::storage::actor_identity()
568        } else {
569            self.actor.trim().to_string()
570        };
571        let mut decision = TriageDecision::new(
572            self.kind,
573            self.timestamp.clone(),
574            actor,
575            self.reason.clone(),
576        );
577        if self.kind == DecisionKind::Accept {
578            let target = self.target.trim();
579            decision.target_status = if target.is_empty() {
580                None
581            } else {
582                Some(target.to_string())
583            };
584            if decision.target_status.is_none() {
585                decision.target_status = store
586                    .config
587                    .triage
588                    .as_ref()
589                    .and_then(|triage| triage.accepted_status.clone());
590            }
591            decision.assignee =
592                Some(self.assignee.trim().to_string()).filter(|assignee| !assignee.is_empty());
593        }
594        decision
595    }
596
597    /// The `--of` this modal carries, for a duplicate only.
598    #[must_use]
599    pub fn duplicate_target(&self) -> Option<String> {
600        let raw = self.duplicate_of.trim();
601        (!raw.is_empty() && self.kind == DecisionKind::Duplicate).then(|| raw.to_string())
602    }
603
604    /// One read-only plan over the modal's scope. **Writes nothing.**
605    ///
606    /// Both the single issue and the marked set go through
607    /// [`batch::build_plan`], which is T031's "route single and batch
608    /// operations through WP03/WP04": one preview engine, one refusal
609    /// vocabulary, one per-item outcome shape, whatever the size of the batch.
610    ///
611    /// # Errors
612    ///
613    /// Propagates a *plan-level* failure (no `[triage]` section, an unresolvable
614    /// `--of`, a `--to` on a decision that has none). A per-item refusal is not
615    /// an error — it is a refused item in the plan, which is where a surface
616    /// that reports every outcome has to put it.
617    pub fn plan(&self, store: &Store, issues: &[(u32, Issue)]) -> Result<TriagePlan> {
618        let selection = match &self.scope {
619            ModalScope::One(number) => Selection::from_issues(
620                [store.resolve_issue(&number.to_string())?],
621                SelectionSource::Explicit,
622                SelectionScope::Active,
623            ),
624            ModalScope::Many(marked) => marked.to_selection(issues).0,
625        };
626        batch::build_plan(
627            store,
628            selection,
629            self.request(store),
630            self.duplicate_target().as_deref(),
631        )
632    }
633
634    /// Build the preview. A refusal the modal caused lands in `state`, not in
635    /// the `Result`: an `Err` out of here would reach the event loop, and the
636    /// only thing that could do with it is end the session (the rule WP04 froze
637    /// for `BulkModal::preview`, and the reason `tix triage batch` prints a
638    /// refusal and exits non-zero instead of erroring).
639    pub fn preview(&mut self, store: &Store, issues: &[(u32, Issue)]) -> Result<()> {
640        if let ModalScope::Many(marked) = &self.scope {
641            if marked.is_empty() {
642                self.state =
643                    ModalState::NeedsInput("press `space` on a card to mark it".to_string());
644                return Ok(());
645            }
646            // A marked number that has left the inbox between marking and
647            // previewing is left out and **named**, the same way
648            // `BulkModal::preview` reports a card that left the board.
649            let (_selection, dropped) = marked.to_selection(issues);
650            self.note = (!dropped.is_empty()).then(|| {
651                format!(
652                    "{} marked issue(s) left the inbox and were left out: #{}",
653                    dropped.len(),
654                    dropped
655                        .iter()
656                        .map(u32::to_string)
657                        .collect::<Vec<_>>()
658                        .join(", #")
659                )
660            });
661        }
662        match self.plan(store, issues) {
663            Ok(plan) => {
664                self.state = ModalState::Preview(Box::new(plan));
665                self.plan_stale = false;
666            }
667            Err(e) => {
668                self.state = ModalState::Refused(e.to_string());
669                self.note = Some(e.to_string());
670            }
671        }
672        Ok(())
673    }
674
675    /// `y`: from a preview, arm. From an armed preview, apply.
676    ///
677    /// Two presses because "I saw it" and "I approved it" are two decisions;
678    /// one `y` conflates them (WP07's rule, kept rather than reinvented).
679    ///
680    /// The **stale** check runs before either branch: a plan built before the
681    /// last field edit is a plan nobody read as it now stands, so the apply
682    /// refuses rather than asking the second `y` to mean something different
683    /// from what the first one did.
684    ///
685    /// # Errors
686    ///
687    /// Only an I/O failure behind the keyboard. A refusal lands in
688    /// [`ModalState::Refused`], because the only thing an `Err` out of here
689    /// could do is end the session.
690    pub fn confirm(&mut self, store: &Store) -> Result<()> {
691        if self.plan_stale
692            && matches!(
693                self.state,
694                ModalState::Preview(_) | ModalState::Confirming(_)
695            )
696        {
697            self.state = ModalState::Refused(
698                "the fields changed since the preview — press `p` again to see what would \
699                 happen now"
700                    .to_string(),
701            );
702            return Ok(());
703        }
704        match &self.state {
705            ModalState::Preview(plan) => {
706                self.state = ModalState::Confirming(plan.clone());
707                Ok(())
708            }
709            ModalState::Confirming(plan) => {
710                let mut plan = (**plan).clone();
711                plan.confirm();
712                match batch::apply(store, &plan) {
713                    Ok(report) => {
714                        let text = cli_triage::render_report(&report, &plan);
715                        self.state = ModalState::Done(text);
716                        self.plan_stale = false;
717                    }
718                    Err(e) => {
719                        self.state = ModalState::Refused(e.to_string());
720                        self.note = Some(e.to_string());
721                    }
722                }
723                Ok(())
724            }
725            _ => Ok(()),
726        }
727    }
728
729    #[must_use]
730    pub fn is_armed(&self) -> bool {
731        matches!(self.state, ModalState::Confirming(_))
732    }
733
734    #[must_use]
735    pub fn state_label(&self) -> &'static str {
736        match &self.state {
737            ModalState::Editing => "editing",
738            ModalState::NeedsInput(_) => "needs input",
739            ModalState::Preview(_) | ModalState::Confirming(_) if self.plan_stale => {
740                "stale — preview again"
741            }
742            ModalState::Preview(_) => "preview — nothing written",
743            ModalState::Confirming(_) => "confirming — y writes",
744            ModalState::Done(_) => "done",
745            ModalState::Refused(_) => "refused",
746        }
747    }
748
749    /// The footer: only the keys that mean something **right now**. A footer
750    /// that names `y` while `y` types a letter teaches the wrong thing.
751    #[must_use]
752    pub fn footer(&self) -> String {
753        if self.editing {
754            return "enter commit · esc revert".to_string();
755        }
756        if self.plan_stale
757            && matches!(
758                self.state,
759                ModalState::Preview(_) | ModalState::Confirming(_)
760            )
761        {
762            return "press p to re-preview · esc to cancel".to_string();
763        }
764        match &self.state {
765            ModalState::Preview(_) => "y to confirm · p to re-preview · esc to cancel".to_string(),
766            ModalState::Confirming(_) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
767            ModalState::Done(_) | ModalState::Refused(_) | ModalState::NeedsInput(_) => {
768                "p to re-preview · esc to close".to_string()
769            }
770            ModalState::Editing if self.ok_focused => {
771                "enter OK · tab fields · t target · p preview · esc cancel".to_string()
772            }
773            ModalState::Editing => {
774                "a decision · j/k field · enter edit/select · t target · p preview · esc cancel"
775                    .to_string()
776            }
777        }
778    }
779
780    /// The rows, as the modal draws them: a cursor, a label and a value.
781    #[must_use]
782    pub fn rows(&self) -> Vec<(bool, &'static str, String)> {
783        self.fields
784            .iter()
785            .enumerate()
786            .map(|(index, field)| {
787                let mut value = if self.editing && index == self.cursor {
788                    format!("{}█", self.draft)
789                } else {
790                    let value = self.field_value(*field);
791                    if value.is_empty() {
792                        "—".to_string()
793                    } else {
794                        value.to_string()
795                    }
796                };
797                if *field == DecisionField::Target {
798                    value.push_str(" ▾");
799                }
800                (
801                    !self.ok_focused && index == self.cursor,
802                    field.label(self.kind),
803                    value,
804                )
805            })
806            .collect()
807    }
808
809    /// The text a surface shows for the current state, from the **CLI's**
810    /// renderers wherever the CLI has one.
811    #[must_use]
812    pub fn body(&self) -> String {
813        match &self.state {
814            ModalState::Editing => {
815                let mut out = format!("decision: {}\n", self.kind);
816                if let Some(question) = self.confirmation_question() {
817                    out.push_str(&question);
818                    out.push('\n');
819                }
820                out.push_str("fields (required first; enter to edit):\n");
821                for (on, name, value) in self.rows() {
822                    let cursor = if on { ">" } else { " " };
823                    out.push_str(&format!("{cursor} {name}: {value}\n"));
824                }
825                if let Some(note) = &self.note {
826                    out.push_str(&format!("\nnote: {note}\n"));
827                }
828                out.push('\n');
829                out.push_str(&self.footer());
830                out
831            }
832            ModalState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
833            // The plan table, verbatim from the CLI: `render_plan` is what
834            // `tix triage batch --select …` prints for this same plan.
835            ModalState::Preview(plan) => {
836                let mut out = String::new();
837                if let Some(question) = self.confirmation_question() {
838                    out.push_str(&question);
839                    out.push_str("\n\n");
840                }
841                out.push_str(&cli_triage::render_plan(plan));
842                if let Some(note) = &self.note {
843                    out.push_str(&format!("\nnote: {note}\n"));
844                }
845                out.push('\n');
846                out.push_str(&self.footer());
847                out
848            }
849            ModalState::Confirming(plan) => {
850                let mut out = String::new();
851                if let Some(question) = self.confirmation_question() {
852                    out.push_str(&question);
853                    out.push_str("\n\n");
854                }
855                out.push_str(&cli_triage::render_plan(plan));
856                out.push('\n');
857                out.push_str(&self.footer());
858                out
859            }
860            ModalState::Done(text) | ModalState::Refused(text) => text.clone(),
861        }
862    }
863}
864
865// ---------------------------------------------------------------------------
866// T029 — the inbox
867// ---------------------------------------------------------------------------
868
869/// Which picker the Triage mode has open (tui-triage-mode).
870#[derive(Debug, Clone, Copy, PartialEq, Eq)]
871pub enum TriageDropdownKind {
872    /// The board's columns, as a status to move the focused issue to.
873    Status,
874    /// The board's columns, as the destination of an accept decision.
875    DecisionTarget,
876    /// The assignee storage folders plus configured team members.
877    Assignee,
878}
879
880/// An open picker in the Triage mode: the reused [`DropdownState`] and which
881/// field it is choosing for. Exactly one can be open at a time.
882#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct TriageDropdown {
884    pub kind: TriageDropdownKind,
885    pub state: DropdownState,
886}
887
888/// Which control of the Triage mode's toolbar has the focus (tui-triage-mode).
889///
890/// The mode's horizontal axis: `h`/`l` and `←`/`→` walk these four and clamp at
891/// the ends. The queue is a control rather than "the absence of one" so the
892/// default focus has a visible home on the bar, and `enter` activates whatever
893/// is focused — nothing at all for [`Self::Queue`], whose keys (`j`/`k`,
894/// `space`, `a`/`d`/`u`/`f`/`p`) act directly on the list and need no
895/// activation step.
896#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
897pub enum TriageControl {
898    /// The queue itself.
899    #[default]
900    Queue,
901    /// The status picker (`s`).
902    Status,
903    /// The assignee picker (`@`).
904    Assignee,
905    /// The inline create prompt (`n`).
906    New,
907}
908
909impl TriageControl {
910    /// The four controls, in the order the toolbar draws them and `h`/`l` walk
911    /// them.
912    pub const ALL: [Self; 4] = [Self::Queue, Self::Status, Self::Assignee, Self::New];
913}
914
915/// The inbox view's own state: a cursor and a selection that are **not** the
916/// board's, so opening and closing the inbox leaves
917/// `(active_col, selected)` exactly as it was (T029).
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct TriageState {
920    /// The queue, in the order the store lists it (or oldest-first under `A`).
921    pub rows: Vec<QueueRow>,
922    /// The header's counts.
923    pub summary: TriageSummary,
924    /// The row under the cursor.
925    pub cursor: usize,
926    /// Which toolbar control has the focus. `Queue` on entry, so `j`/`k` mean
927    /// exactly what they always did until `h`/`l` moves the focus off it.
928    pub focus: TriageControl,
929    /// The marked issues — #173's own ordered set, reused rather than grown
930    /// beside it (the "duplicate selection model" risk, mitigated).
931    pub selection: BulkSelection,
932    /// Oldest-first, the TUI's `--age`.
933    pub by_age: bool,
934    /// The decision modal, `Some` exactly while one is open.
935    pub modal: Option<TriageModal>,
936    /// The status/assignee picker, `Some` exactly while one is open. A dropdown
937    /// never coexists with a decision modal, so a letter typed into a decline
938    /// reason can never be stolen by a picker (tui-triage-mode).
939    pub dropdown: Option<TriageDropdown>,
940    /// Whether the inline create prompt is capturing the keyboard.
941    pub creating: bool,
942    /// The title being typed into the inline create prompt.
943    pub create_buffer: String,
944    /// The last message worth showing under the list.
945    pub note: Option<String>,
946}
947
948/// The status dropdown's options: exactly the active board's columns, in board
949/// order (tui-triage-mode, "Status options match the board columns").
950#[must_use]
951pub fn status_options(columns: &[String]) -> Vec<PickerItem> {
952    columns
953        .iter()
954        .map(|column| PickerItem {
955            id: column.clone(),
956            label: column.clone(),
957        })
958        .collect()
959}
960
961/// The assignee dropdown's options: the `docs/issues/<username>/` storage
962/// folders, the configured `teams.*.members`, and the focused issue's current
963/// value (design D6), each once, in that precedence order.
964///
965/// Deriving from the folders is the point: storage layout is the ground truth
966/// for who has issues, and a teams-only list misses real users.
967#[must_use]
968pub fn assignee_options(store: &Store, current: Option<&str>) -> Vec<PickerItem> {
969    let mut seen = std::collections::BTreeSet::new();
970    let mut out = Vec::new();
971    let mut push = |value: &str, out: &mut Vec<PickerItem>| {
972        let value = value.trim();
973        if value.is_empty() || !seen.insert(value.to_string()) {
974            return;
975        }
976        out.push(PickerItem {
977            id: value.to_string(),
978            label: value.to_string(),
979        });
980    };
981    if let Ok(folders) = store.assignee_segments() {
982        for folder in folders {
983            push(&folder, &mut out);
984        }
985    }
986    for team in store.config.teams.values() {
987        for member in &team.members {
988            push(member, &mut out);
989        }
990    }
991    if let Some(current) = current {
992        push(current, &mut out);
993    }
994    out
995}
996
997impl TriageState {
998    /// Query the queue and open the inbox.
999    ///
1000    /// # Errors
1001    ///
1002    /// A project with no `[triage]` section has no inbox, and the refusal is
1003    /// raised **before** the mode is entered — the board does not change under
1004    /// a user who pressed `t` in a project that does not use triage.
1005    pub fn open(store: &Store) -> Result<Self> {
1006        let mut state = Self {
1007            rows: Vec::new(),
1008            summary: TriageSummary {
1009                total: 0,
1010                awaiting: 0,
1011                deferred: 0,
1012                accepted: 0,
1013                declined: 0,
1014                duplicated: 0,
1015                unknown_age: 0,
1016                age: Vec::new(),
1017            },
1018            cursor: 0,
1019            focus: TriageControl::Queue,
1020            selection: BulkSelection::new(),
1021            by_age: false,
1022            modal: None,
1023            dropdown: None,
1024            creating: false,
1025            create_buffer: String::new(),
1026            note: None,
1027        };
1028        state.refresh(store)?;
1029        Ok(state)
1030    }
1031
1032    /// Re-query the queue. Called on open and after every apply, so the counts
1033    /// are never a stale snapshot of a file that has moved on.
1034    ///
1035    /// # Errors
1036    ///
1037    /// Propagates the scope refusal and any I/O failure.
1038    pub fn refresh(&mut self, store: &Store) -> Result<()> {
1039        // `scope_of` rather than `inbox::scope`, so the refusal this view shows
1040        // is the one refusal there is — a project with no `[triage]` section
1041        // has no inbox, and saying so in two different words would make the
1042        // TUI and the CLI disagree about the same missing config.
1043        let scope = scope_of(store)?;
1044        let corpus = crate::search::Corpus::build(store)?;
1045        // The same two calls `commands::triage::render_list` makes: one
1046        // snapshot, one scope, one `today`, one pass.
1047        let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
1048        self.summary = TriageSummary::of(&metrics);
1049        self.rows = metrics
1050            .entries
1051            .iter()
1052            .map(|entry| QueueRow {
1053                issue: (*entry.issue).clone(),
1054                age_days: entry.age_days,
1055                last_decision: last_decision(entry.issue),
1056            })
1057            .collect();
1058        if self.by_age {
1059            self.sort_by_age();
1060        }
1061        self.clamp_cursor();
1062        Ok(())
1063    }
1064
1065    /// Oldest first, with an unknown age sorted **last** on both ends rather
1066    /// than coerced to `0` — the rule `commands::triage::render_list` gives
1067    /// `--age`, restated rather than re-invented.
1068    fn sort_by_age(&mut self) {
1069        self.rows.sort_by(|a, b| match (a.age_days, b.age_days) {
1070            (Some(x), Some(y)) => y.cmp(&x),
1071            (Some(_), None) => std::cmp::Ordering::Less,
1072            (None, Some(_)) => std::cmp::Ordering::Greater,
1073            (None, None) => std::cmp::Ordering::Equal,
1074        });
1075    }
1076
1077    fn clamp_cursor(&mut self) {
1078        if self.rows.is_empty() {
1079            self.cursor = 0;
1080        } else if self.cursor >= self.rows.len() {
1081            self.cursor = self.rows.len() - 1;
1082        }
1083    }
1084
1085    pub fn move_cursor(&mut self, delta: isize) {
1086        if self.rows.is_empty() {
1087            return;
1088        }
1089        let len = self.rows.len() as isize;
1090        let next = (self.cursor as isize + delta).rem_euclid(len);
1091        self.cursor = next as usize;
1092    }
1093
1094    /// Move the focus one control left or right, **clamped** at the ends: the
1095    /// control bar is a rail, so `h` on `queue` and `l` on `new` stay put
1096    /// rather than wrapping to the other end (tui-triage-mode).
1097    ///
1098    /// The queue cursor does not move with the focus. The two are different
1099    /// things: `j`/`k` keep walking the queue whichever control is focused, and
1100    /// the focus only decides what `enter` activates.
1101    pub fn move_focus(&mut self, delta: isize) {
1102        let index = TriageControl::ALL
1103            .iter()
1104            .position(|control| *control == self.focus)
1105            .unwrap_or_default() as isize;
1106        let last = TriageControl::ALL.len() as isize - 1;
1107        self.focus = TriageControl::ALL[(index + delta).clamp(0, last) as usize];
1108    }
1109
1110    #[must_use]
1111    pub fn current(&self) -> Option<&QueueRow> {
1112        self.rows.get(self.cursor)
1113    }
1114
1115    /// Open `kind`'s picker, preselecting `current` when it is one of the
1116    /// options. The options come from the caller (the board columns / the
1117    /// assignee derivation), so this view never re-derives them.
1118    pub fn open_dropdown(
1119        &mut self,
1120        kind: TriageDropdownKind,
1121        options: Vec<PickerItem>,
1122        current: Option<&str>,
1123    ) {
1124        let mut state = DropdownState::single(options);
1125        if let Some(current) = current
1126            && let Some(index) = state.options.iter().position(|option| option.id == current)
1127            && let Some(visible) = state
1128                .visible_indices()
1129                .iter()
1130                .position(|visible| *visible == index)
1131        {
1132            state.cursor = visible;
1133        }
1134        self.dropdown = Some(TriageDropdown { kind, state });
1135    }
1136
1137    /// Drop the open picker without choosing anything.
1138    pub fn close_dropdown(&mut self) {
1139        self.dropdown = None;
1140    }
1141
1142    /// Feed one key to the open picker. `Ok(Some((kind, id)))` is a choice,
1143    /// `Ok(None)` means the key was consumed without choosing, and `Err` is
1144    /// unreachable — a picker cancel is a `None`, not an error.
1145    #[must_use]
1146    pub fn handle_dropdown_key(
1147        &mut self,
1148        key: crossterm::event::KeyCode,
1149    ) -> Option<(TriageDropdownKind, String)> {
1150        let dropdown = self.dropdown.as_mut()?;
1151        match dropdown.state.handle_key(key) {
1152            PickerEvent::Cancelled => {
1153                self.dropdown = None;
1154                None
1155            }
1156            PickerEvent::Selected(ids) => {
1157                let kind = dropdown.kind;
1158                let id = ids.first().cloned();
1159                self.dropdown = None;
1160                id.map(|id| (kind, id))
1161            }
1162            // The pickers here have no inline-create row; `Created` cannot happen
1163            // because `+`/`Insert` is the only route to it and the triage handler
1164            // never forwards those keys. `None` is the honest answer.
1165            PickerEvent::Created(_) | PickerEvent::None => None,
1166        }
1167    }
1168
1169    /// Toggle the row under the cursor in the marked selection.
1170    ///
1171    /// An unmarked row is added; a marked row is removed. A full selection is
1172    /// reported rather than silently dropping the add.
1173    pub fn toggle_current(&mut self) {
1174        let Some(row) = self.rows.get(self.cursor) else {
1175            return;
1176        };
1177        if self.selection.contains(row.issue.number) {
1178            self.selection.remove(row.issue.number);
1179            return;
1180        }
1181        match self.selection.toggle(row.issue.number) {
1182            Ok(_) => {}
1183            Err(problem) => self.note = Some(problem.to_string()),
1184        }
1185    }
1186
1187    /// Open the decision modal for the row under the cursor.
1188    pub fn open_single(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1189        let Some(row) = self.rows.get(self.cursor) else {
1190            self.note = Some("the inbox is empty — there is nothing to decide".to_string());
1191            return false;
1192        };
1193        let heading = format!("#{} {}", row.issue.number, row.issue.title);
1194        let mut modal = TriageModal::new(kind, ModalScope::One(row.issue.number), heading);
1195        modal.prefill_defaults(store, columns);
1196        self.modal = Some(modal);
1197        true
1198    }
1199
1200    /// Open a decision over the marked set when one exists, otherwise over the
1201    /// row under the cursor.
1202    pub fn open_action(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1203        if self.selection.is_empty() {
1204            self.open_single(kind, store, columns)
1205        } else {
1206            self.open_batch(kind, store, columns)
1207        }
1208    }
1209
1210    /// Open the decision modal over everything marked.
1211    pub fn open_batch(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1212        if self.selection.is_empty() {
1213            self.note = Some("press `space` on a card to mark it first".to_string());
1214            return false;
1215        }
1216        let heading = format!("{} marked issue(s)", self.selection.len());
1217        let mut modal = TriageModal::new(kind, ModalScope::Many(self.selection.clone()), heading);
1218        modal.prefill_defaults(store, columns);
1219        self.modal = Some(modal);
1220        true
1221    }
1222
1223    /// Drop the modal and whatever state it was in. The queue and the
1224    /// selection are untouched: a cancel changes nothing, which is T032's
1225    /// claim about cancellation in full.
1226    pub fn close_modal(&mut self) {
1227        self.modal = None;
1228    }
1229
1230    /// The snapshot the plan is built from, as the batch modal asks for it.
1231    fn issues(&self) -> Vec<(u32, Issue)> {
1232        self.rows
1233            .iter()
1234            .map(|row| (row.issue.number, row.issue.clone()))
1235            .collect()
1236    }
1237
1238    /// Preview the open decision. Writes nothing, in every branch.
1239    ///
1240    /// # Errors
1241    /// Propagates an I/O failure behind the keyboard; a *refusal* is reported
1242    /// through [`ModalState::Refused`].
1243    pub fn preview(&mut self, store: &Store) -> Result<()> {
1244        let issues = self.issues();
1245        if let Some(modal) = self.modal.as_mut() {
1246            modal.preview(store, &issues)?;
1247        }
1248        Ok(())
1249    }
1250
1251    /// `y` on the open decision: arm, then apply and close.
1252    ///
1253    /// # Errors
1254    /// Propagates an I/O failure behind the keyboard only.
1255    pub fn confirm(&mut self, store: &Store) -> Result<()> {
1256        // No `issues` here: the plan that is applied is the one the preview
1257        // built and stored, re-checked by `batch::apply` for freshness — not a
1258        // second plan rebuilt from a snapshot that may have moved on.
1259        if let Some(modal) = self.modal.as_mut() {
1260            modal.confirm(store)?;
1261        }
1262        if self.modal_finished() {
1263            let report = self.modal.as_ref().and_then(|modal| match &modal.state {
1264                ModalState::Done(text) => Some(text.clone()),
1265                _ => None,
1266            });
1267            self.refresh(store)?;
1268            self.modal = None;
1269            self.note = report;
1270        }
1271        Ok(())
1272    }
1273
1274    /// Advance an open decision through the same preview → arm → apply path as
1275    /// the keyboard when the modal's `OK` button is clicked.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Propagates an I/O failure from previewing or applying the plan.
1280    pub fn modal_ok(&mut self, store: &Store) -> Result<()> {
1281        match self.modal.as_ref().map(|modal| &modal.state) {
1282            Some(ModalState::Editing | ModalState::NeedsInput(_) | ModalState::Refused(_)) => {
1283                self.preview(store)?
1284            }
1285            Some(ModalState::Preview(_)) => self.confirm(store)?,
1286            Some(ModalState::Confirming(_)) => self.confirm(store)?,
1287            Some(ModalState::Done(_)) => self.close_modal(),
1288            None => {}
1289        }
1290        Ok(())
1291    }
1292
1293    /// Whether the modal finished, so the caller can refresh the queue and
1294    /// report the result the way `BulkModal`'s handler does.
1295    #[must_use]
1296    pub fn modal_finished(&self) -> bool {
1297        matches!(
1298            self.modal.as_ref().map(|modal| &modal.state),
1299            Some(ModalState::Done(_))
1300        )
1301    }
1302
1303    /// The note worth showing under the list, preferring a completed apply's
1304    /// report over an older message.
1305    #[must_use]
1306    pub fn display_note(&self) -> Option<String> {
1307        match self.modal.as_ref().map(|modal| &modal.state) {
1308            Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => Some(text.clone()),
1309            _ => self.note.clone(),
1310        }
1311    }
1312
1313    /// The inbox's own footer: what the list can do right now.
1314    #[must_use]
1315    pub fn footer(&self) -> String {
1316        if self.creating {
1317            return "enter create · esc cancel".to_string();
1318        }
1319        if self.selection.is_empty() {
1320            "h/l focus · enter activate · j/k move · space toggle · a accept · d decline · u duplicate · f defer · p batch · s status · @ assignee · n new · A age · esc back"
1321                .to_string()
1322        } else {
1323            format!(
1324                "{} marked — space toggles · a/d/u/f apply to all · p batch accept · s status · @ assignee · esc back",
1325                self.selection.len()
1326            )
1327        }
1328    }
1329
1330    /// The text the inbox pane shows.
1331    #[must_use]
1332    pub fn body(&self) -> String {
1333        self.body_with_width(usize::MAX)
1334    }
1335
1336    fn body_with_width(&self, width: usize) -> String {
1337        let mut out = String::new();
1338        let _ = writeln!(out, "{}", self.summary.header());
1339        let _ = writeln!(out, "{}", self.summary.age_line());
1340        let _ = writeln!(out, "{}", self.summary.decisions_line());
1341        out.push('\n');
1342        if self.rows.is_empty() {
1343            out.push_str("the inbox is empty\n");
1344        }
1345        for (index, row) in self.rows.iter().enumerate() {
1346            let cursor = if index == self.cursor { ">" } else { " " };
1347            let mark = if self.selection.contains(row.issue.number) {
1348                "*"
1349            } else {
1350                " "
1351            };
1352            let prefix = format!(
1353                "{cursor}{mark}{:<4} {:<8} ",
1354                row.issue.number,
1355                age_cell(row.age_days),
1356            );
1357            let prefix_width = UnicodeWidthStr::width(prefix.as_str());
1358            let title_lines =
1359                wrap_title(&row.issue.title, width.saturating_sub(prefix_width).max(1));
1360            let indent = " ".repeat(prefix_width);
1361            for (line_index, title_line) in title_lines.iter().enumerate() {
1362                if line_index == 0 {
1363                    let _ = writeln!(out, "{prefix}{title_line}");
1364                } else {
1365                    let _ = writeln!(out, "{indent}{title_line}");
1366                }
1367            }
1368        }
1369        if let Some(row) = self.current() {
1370            let _ = writeln!(out, "\n{}", decision_summary(row));
1371        }
1372        if let Some(note) = self.display_note() {
1373            let _ = writeln!(out, "\n{note}");
1374        }
1375        let _ = write!(out, "\n{}", self.footer());
1376        out
1377    }
1378}
1379
1380/// Wrap a title into the title column, keeping word boundaries where possible.
1381///
1382/// A word wider than the column is split by display width. That is the same
1383/// fallback `tix list`'s wrapping cells use, so a long slug or URL remains
1384/// readable instead of being clipped.
1385fn wrap_title(title: &str, width: usize) -> Vec<String> {
1386    if title.is_empty() || width == 0 {
1387        return vec![String::new()];
1388    }
1389    let mut lines = Vec::new();
1390    let mut line = String::new();
1391    for word in title.split_whitespace() {
1392        let word_width = UnicodeWidthStr::width(word);
1393        let candidate_width =
1394            UnicodeWidthStr::width(line.as_str()) + usize::from(!line.is_empty()) + word_width;
1395        if !line.is_empty() && candidate_width > width {
1396            lines.push(std::mem::take(&mut line));
1397        }
1398        if word_width <= width {
1399            if !line.is_empty() {
1400                line.push(' ');
1401            }
1402            line.push_str(word);
1403        } else {
1404            let mut chunk = String::new();
1405            for character in word.chars() {
1406                let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
1407                if !chunk.is_empty()
1408                    && UnicodeWidthStr::width(chunk.as_str()) + character_width > width
1409                {
1410                    lines.push(std::mem::take(&mut chunk));
1411                }
1412                chunk.push(character);
1413            }
1414            line = chunk;
1415        }
1416    }
1417    if !line.is_empty() || lines.is_empty() {
1418        lines.push(line);
1419    }
1420    lines
1421}
1422
1423/// The age cell: a number of days, `today` for zero, `unknown` for no stamp.
1424///
1425/// Duplicated from `commands::triage::age_cell`, which is private to that
1426/// module: a reader who sees `0` believes the issue arrived today, and
1427/// `unknown` is the whole point of the entry stamp. The two spellings are
1428/// pinned together by
1429/// `triage_tui_the_inbox_counts_are_the_clis_counts_for_the_same_snapshot`,
1430/// which strips the inbox row's cursor and mark and compares the rest with
1431/// `tix triage list`'s own row — byte for byte, age cell included.
1432fn age_cell(age: Option<i64>) -> String {
1433    match age {
1434        Some(0) => "today".to_string(),
1435        Some(days) => format!("{days}d"),
1436        None => "unknown".to_string(),
1437    }
1438}
1439
1440// ---------------------------------------------------------------------------
1441// Rendering
1442// ---------------------------------------------------------------------------
1443
1444/// Render the Triage mode's content: a toolbar, the queue, and — over it — the
1445/// decision modal or an open picker.
1446///
1447/// Never asserts on pixels; a test reads the buffer or the hit regions this
1448/// records.
1449pub fn render(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
1450    let block = Block::default()
1451        .borders(Borders::ALL)
1452        .title(" triage ")
1453        .border_style(Style::default().fg(Color::Cyan));
1454    let inner = block.inner(area);
1455    frame.render_widget(block, area);
1456
1457    let chunks = Layout::default()
1458        .direction(Direction::Vertical)
1459        .constraints([Constraint::Length(1), Constraint::Min(1)])
1460        .split(inner);
1461
1462    // The toolbar: the queue, the two pickers and the create action, each with
1463    // its own clickable region (keyboard parity: `s`, `@`, `n` — and `h`/`l`
1464    // to walk the bar). The focused control is drawn inverse; that is the whole
1465    // focus model on screen.
1466    let labels = control_labels(state);
1467    let mut spans: Vec<Span> = Vec::new();
1468    for (index, (control, label)) in labels.iter().enumerate() {
1469        if index > 0 {
1470            spans.push(Span::raw("   "));
1471        }
1472        spans.push(Span::styled(
1473            label.clone(),
1474            control_style(*control, state.focus),
1475        ));
1476    }
1477    frame.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
1478
1479    if state.modal.is_none() && !state.creating {
1480        for (control, rect) in control_rects(area, state) {
1481            app.record_region(rect, control_action(control));
1482        }
1483    }
1484
1485    if state.creating {
1486        let line = Line::from(vec![
1487            Span::styled(
1488                "new issue title: ",
1489                Style::default().add_modifier(Modifier::BOLD),
1490            ),
1491            Span::raw(format!("{}█", state.create_buffer)),
1492        ]);
1493        frame.render_widget(Paragraph::new(line), chunks[1]);
1494        return;
1495    }
1496
1497    frame.render_widget(
1498        Paragraph::new(state.body_with_width(usize::from(chunks[1].width))),
1499        chunks[1],
1500    );
1501
1502    if let Some(modal) = state.modal.as_ref() {
1503        render_modal(app, frame, area, modal);
1504    }
1505    if state.dropdown.is_some() {
1506        render_dropdown(app, frame, area, state);
1507    }
1508}
1509
1510/// The toolbar's labels, in [`TriageControl::ALL`] order.
1511///
1512/// The three labelled controls keep the `[s]`/`[@]`/`[n]` key prefixes a user
1513/// already reads; the queue has no activation key, so its label is the bare
1514/// word.
1515fn control_labels(state: &TriageState) -> [(TriageControl, String); 4] {
1516    let status = state
1517        .current()
1518        .map(|row| row.issue.status.display().to_string())
1519        .unwrap_or_else(|| "—".to_string());
1520    let assignee = state
1521        .current()
1522        .and_then(|row| row.issue.assignee.clone())
1523        .unwrap_or_else(|| "unassigned".to_string());
1524    [
1525        (TriageControl::Queue, "queue".to_string()),
1526        (TriageControl::Status, format!("[s] status: {status}")),
1527        (TriageControl::Assignee, format!("[@] assignee: {assignee}")),
1528        (TriageControl::New, "[n] new issue".to_string()),
1529    ]
1530}
1531
1532/// The style of one toolbar control: its own colour, plus the focus marker when
1533/// it is the focused one.
1534///
1535/// Inverse rather than a second pair of brackets: the labels already carry
1536/// `[s]`/`[@]`/`[n]`, and a bracket around a bracket reads as a third key.
1537fn control_style(control: TriageControl, focus: TriageControl) -> Style {
1538    let color = match control {
1539        TriageControl::Queue => Color::White,
1540        TriageControl::Status => Color::Cyan,
1541        TriageControl::Assignee => Color::Green,
1542        TriageControl::New => Color::Yellow,
1543    };
1544    let style = Style::default().fg(color);
1545    if control == focus {
1546        style.add_modifier(Modifier::REVERSED | Modifier::BOLD)
1547    } else {
1548        style
1549    }
1550}
1551
1552/// What a click on `control` records: activating it — the same thing `enter`
1553/// does — except for the queue, whose click only moves the focus there.
1554fn control_action(control: TriageControl) -> Action {
1555    match control {
1556        TriageControl::Queue => Action::TriageFocusQueue,
1557        TriageControl::Status => Action::TriageOpenStatus,
1558        TriageControl::Assignee => Action::TriageOpenAssignee,
1559        TriageControl::New => Action::TriageCreate,
1560    }
1561}
1562
1563fn label_width(label: &str) -> u16 {
1564    label.chars().count() as u16
1565}
1566
1567/// The click rects of the toolbar controls, in [`TriageControl::ALL`] order —
1568/// the same geometry [`render`] records, exposed so a test can click a control,
1569/// or check which one carries the focus, without re-deriving the layout.
1570#[must_use]
1571pub fn control_rects(area: Rect, state: &TriageState) -> [(TriageControl, Rect); 4] {
1572    let inner = Block::default().borders(Borders::ALL).inner(area);
1573    let mut x = inner.x;
1574    control_labels(state).map(|(control, label)| {
1575        let width = label_width(&label).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
1576        let rect = Rect::new(x, inner.y, width, 1);
1577        x = x.saturating_add(width).saturating_add(3);
1578        (control, rect)
1579    })
1580}
1581
1582/// Where an open picker draws inside `area`, and the rect of each visible
1583/// option row. `None` when no picker is open or there is nothing to show.
1584#[must_use]
1585pub fn dropdown_layout(area: Rect, state: &TriageState) -> Option<(Rect, Vec<Rect>)> {
1586    let dropdown = state.dropdown.as_ref()?;
1587    let visible = dropdown.state.visible_indices();
1588    if visible.is_empty() {
1589        return None;
1590    }
1591    let container = if dropdown.kind == TriageDropdownKind::DecisionTarget {
1592        crate::ui::modal::modal_rect(area)
1593    } else {
1594        area
1595    };
1596    let inner = Block::default().borders(Borders::ALL).inner(container);
1597    let inner = if dropdown.kind == TriageDropdownKind::DecisionTarget {
1598        Rect {
1599            y: inner.y.saturating_add(2),
1600            height: inner.height.saturating_sub(2),
1601            ..inner
1602        }
1603    } else {
1604        inner
1605    };
1606    let widest = visible
1607        .iter()
1608        .map(|index| label_width(&dropdown.state.options[*index].label))
1609        .max()
1610        .unwrap_or(0);
1611    let height = (visible.len() as u16 + 2).min(inner.height.max(3));
1612    if height < 3 || inner.width == 0 {
1613        return None;
1614    }
1615    let title_width = label_width(dropdown_title(dropdown.kind)) + 2;
1616    let width = (widest + 4).max(title_width).min(inner.width);
1617    let rect = Rect::new(inner.x, inner.y, width, height);
1618    let rows = (0..visible.len() as u16)
1619        .take(height as usize - 2)
1620        .map(|row| Rect::new(rect.x, rect.y + 1 + row, rect.width, 1))
1621        .collect();
1622    Some((rect, rows))
1623}
1624
1625fn dropdown_title(kind: TriageDropdownKind) -> &'static str {
1626    match kind {
1627        TriageDropdownKind::Status => " choose status ",
1628        TriageDropdownKind::DecisionTarget => " choose column ",
1629        TriageDropdownKind::Assignee => " choose assignee ",
1630    }
1631}
1632
1633/// The clickable `OK` cell directly below the decision modal's heading.
1634#[must_use]
1635pub fn modal_ok_rect(area: Rect) -> Rect {
1636    let inner = Block::default()
1637        .borders(Borders::ALL)
1638        .inner(crate::ui::modal::modal_rect(area));
1639    let width = 8.min(inner.width);
1640    let x = inner.x + inner.width.saturating_sub(width) / 2;
1641    Rect::new(
1642        x,
1643        inner
1644            .y
1645            .saturating_add(1)
1646            .min(inner.bottom().saturating_sub(1)),
1647        width,
1648        1,
1649    )
1650}
1651
1652fn render_modal(app: &App, frame: &mut Frame, area: Rect, modal: &TriageModal) {
1653    let modal_rect = crate::ui::modal::modal_rect(area);
1654    frame.render_widget(Clear, modal_rect);
1655    let modal_block = Block::default()
1656        .borders(Borders::ALL)
1657        .title(format!(" {} — {} ", modal.kind, modal.state_label()))
1658        .border_style(Style::default().fg(Color::Magenta));
1659    let modal_inner = modal_block.inner(modal_rect);
1660    frame.render_widget(modal_block, modal_rect);
1661
1662    let chunks = Layout::default()
1663        .direction(Direction::Vertical)
1664        .constraints([
1665            Constraint::Length(1),
1666            Constraint::Length(1),
1667            Constraint::Min(1),
1668        ])
1669        .split(modal_inner);
1670    frame.render_widget(
1671        Paragraph::new(Line::from(Span::styled(
1672            modal.heading.clone(),
1673            Style::default().add_modifier(Modifier::BOLD),
1674        ))),
1675        chunks[0],
1676    );
1677    let ok = modal_ok_rect(area);
1678    let style = if modal.ok_focused || app.is_hovered(&Action::TriageModalOk) {
1679        Style::default()
1680            .fg(Color::Black)
1681            .bg(Color::Cyan)
1682            .add_modifier(Modifier::BOLD)
1683    } else {
1684        Style::default()
1685            .fg(Color::Yellow)
1686            .add_modifier(Modifier::BOLD)
1687    };
1688    if ok.width > 0 && ok.height > 0 {
1689        frame.render_widget(
1690            Paragraph::new(Line::from(Span::styled("[  OK  ]", style))),
1691            ok,
1692        );
1693        app.record_region(ok, Action::TriageModalOk);
1694    }
1695    frame.render_widget(Paragraph::new(modal.body()), chunks[2]);
1696}
1697
1698fn render_dropdown(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
1699    let Some(dropdown) = state.dropdown.as_ref() else {
1700        return;
1701    };
1702    let Some((rect, rows)) = dropdown_layout(area, state) else {
1703        return;
1704    };
1705    // Clicking anywhere outside the list closes it. Recorded first, so the
1706    // option rows recorded below win on overlap (innermost/last wins).
1707    app.record_region(area, Action::TriageDropdownClose);
1708    frame.render_widget(Clear, rect);
1709    let title = dropdown_title(dropdown.kind);
1710    let block = Block::default().borders(Borders::ALL).title(title);
1711    let inner = block.inner(rect);
1712    frame.render_widget(block, rect);
1713
1714    let visible = dropdown.state.visible_indices();
1715    let lines: Vec<Line> = visible
1716        .iter()
1717        .enumerate()
1718        .map(|(row, index)| {
1719            let option = &dropdown.state.options[*index];
1720            let cursor = if row == dropdown.state.cursor {
1721                ">"
1722            } else {
1723                " "
1724            };
1725            Line::from(format!("{cursor} {}", option.label))
1726        })
1727        .collect();
1728    frame.render_widget(Paragraph::new(lines), inner);
1729    for (row, row_rect) in rows.iter().enumerate() {
1730        app.record_region(*row_rect, Action::TriagePickOption(row));
1731    }
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    #[allow(clippy::wildcard_imports)]
1737    use super::*;
1738
1739    /// Every WP06 unit test is named `triage_tui_*`, matching the gate
1740    /// `cargo test --test tui_state` and the naming the other triage work
1741    /// packages use.
1742    #[test]
1743    fn triage_tui_fields_exist_only_for_the_decision_that_can_use_them() {
1744        let one = ModalScope::One(1);
1745        let accept = TriageModal::new(DecisionKind::Accept, one.clone(), "a".into());
1746        assert_eq!(
1747            accept.fields,
1748            vec![
1749                DecisionField::Target,
1750                DecisionField::Actor,
1751                DecisionField::Reason,
1752                DecisionField::Assignee,
1753            ]
1754        );
1755        let decline = TriageModal::new(DecisionKind::Decline, one.clone(), "d".into());
1756        assert_eq!(
1757            decline.fields,
1758            vec![DecisionField::Reason, DecisionField::Actor],
1759            "`--to` has nowhere to be typed on a decline, so it cannot be"
1760        );
1761        let duplicate = TriageModal::new(DecisionKind::Duplicate, one.clone(), "u".into());
1762        assert_eq!(
1763            duplicate.fields,
1764            vec![
1765                DecisionField::DuplicateOf,
1766                DecisionField::Reason,
1767                DecisionField::Actor,
1768            ]
1769        );
1770        let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
1771        assert_eq!(
1772            defer.fields,
1773            vec![DecisionField::Reason, DecisionField::Actor]
1774        );
1775        assert_eq!(
1776            DecisionField::Target.label(DecisionKind::Accept),
1777            "column (required)"
1778        );
1779        assert_eq!(
1780            DecisionField::Actor.label(DecisionKind::Accept),
1781            "actor (required)"
1782        );
1783        assert_eq!(
1784            DecisionField::Reason.label(DecisionKind::Accept),
1785            "reason (optional)"
1786        );
1787        assert_eq!(
1788            DecisionField::Assignee.label(DecisionKind::Accept),
1789            "assignee (optional)"
1790        );
1791    }
1792
1793    #[test]
1794    fn triage_tui_the_cursor_only_moves_over_the_fields_the_decision_has() {
1795        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1796        modal.move_cursor(1);
1797        assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1798        modal.move_cursor(1);
1799        assert_eq!(
1800            modal.field(modal.cursor),
1801            DecisionField::Reason,
1802            "two fields, so the third step wraps back to the first"
1803        );
1804        modal.move_cursor(-1);
1805        assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1806    }
1807
1808    #[test]
1809    fn triage_tui_a_cancelled_draft_never_reaches_the_row_it_was_typed_into() {
1810        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1811        modal.begin_edit();
1812        for c in "half a reason".chars() {
1813            modal.push_char(c);
1814        }
1815        modal.abort_edit();
1816        assert!(!modal.editing);
1817        assert_eq!(modal.reason, "", "the row never saw the draft");
1818    }
1819
1820    #[test]
1821    fn triage_tui_a_committed_draft_lands_on_the_row_under_the_cursor() {
1822        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1823        modal.begin_edit();
1824        for c in "no budget".chars() {
1825            modal.push_char(c);
1826        }
1827        modal.commit_edit();
1828        assert_eq!(modal.reason, "no budget");
1829        assert_eq!(modal.field_value(modal.field(0)), "no budget");
1830    }
1831
1832    #[test]
1833    fn triage_tui_an_accept_without_a_configured_target_names_none() {
1834        let config = crate::config::Config::default();
1835        let modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1836        let request = modal.request(&Store::open(&config).expect("an empty store opens"));
1837        assert_eq!(
1838            request.target_status, None,
1839            "no `--to` and no `[triage].accepted_status` means the service \
1840             refuses `missing_target`, exactly as the CLI does"
1841        );
1842    }
1843
1844    #[test]
1845    fn triage_tui_ok_is_in_the_keyboard_focus_ring() {
1846        let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1847        assert!(modal.ok_focused);
1848
1849        modal.move_focus(1);
1850        assert!(!modal.ok_focused);
1851        assert_eq!(modal.field(modal.cursor), DecisionField::Target);
1852
1853        modal.move_focus(-1);
1854        assert!(modal.ok_focused);
1855
1856        modal.move_focus(-1);
1857        assert!(!modal.ok_focused);
1858        assert_eq!(modal.field(modal.cursor), DecisionField::Assignee);
1859
1860        modal.move_focus(1);
1861        assert!(modal.ok_focused);
1862    }
1863
1864    #[test]
1865    fn triage_tui_accept_prefills_todo_and_the_actor_without_configuration() {
1866        let config = crate::config::Config::default();
1867        let store = Store::open(&config).expect("an empty store opens");
1868        let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1869        let columns = vec!["triage".to_string(), "todo".to_string()];
1870
1871        modal.prefill_defaults(&store, &columns);
1872
1873        assert_eq!(modal.target, "todo");
1874        assert_eq!(modal.actor, crate::storage::actor_identity());
1875    }
1876
1877    #[test]
1878    fn triage_tui_the_header_is_the_cli_headers_wording() {
1879        let summary = TriageSummary {
1880            total: 3,
1881            awaiting: 2,
1882            deferred: 1,
1883            accepted: 0,
1884            declined: 0,
1885            duplicated: 0,
1886            unknown_age: 1,
1887            age: Vec::new(),
1888        };
1889        assert_eq!(
1890            summary.header(),
1891            "inbox: 3 entries (2 awaiting, 1 deferred)"
1892        );
1893    }
1894
1895    #[test]
1896    fn triage_tui_an_unknown_age_is_unknown_and_never_zero() {
1897        assert_eq!(age_cell(None), "unknown");
1898        assert_eq!(age_cell(Some(0)), "today");
1899        assert_eq!(age_cell(Some(7)), "7d");
1900    }
1901
1902    #[test]
1903    fn triage_tui_a_long_unbroken_title_is_split_to_the_title_width() {
1904        let lines = wrap_title("alpha-beta-gamma-delta", 10);
1905        assert_eq!(lines, vec!["alpha-beta", "-gamma-del", "ta"]);
1906        assert!(
1907            lines
1908                .iter()
1909                .all(|line| UnicodeWidthStr::width(line.as_str()) <= 10)
1910        );
1911    }
1912
1913    #[test]
1914    fn triage_tui_the_footer_names_only_keys_that_mean_something_now() {
1915        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1916        assert!(modal.footer().contains("p preview"), "editing: preview");
1917        modal.begin_edit();
1918        assert!(
1919            modal.footer().contains("enter commit"),
1920            "a letter while editing must not be advertised as a command"
1921        );
1922        assert!(!modal.footer().contains("p preview"));
1923    }
1924
1925    #[test]
1926    fn triage_tui_status_options_are_the_board_columns_in_order() {
1927        let columns = vec!["triage".to_string(), "todo".to_string(), "done".to_string()];
1928        let labels: Vec<String> = status_options(&columns)
1929            .into_iter()
1930            .map(|option| option.label)
1931            .collect();
1932        assert_eq!(labels, vec!["triage", "todo", "done"]);
1933    }
1934
1935    #[test]
1936    fn triage_tui_assignee_options_are_folders_teams_and_current() {
1937        let tmp = tempfile::TempDir::new().unwrap();
1938        let mut config = crate::config::Config {
1939            issues_dir: tmp.path().to_path_buf(),
1940            ..crate::config::Config::default()
1941        };
1942        std::fs::create_dir_all(config.issues_dir.join("alice")).unwrap();
1943        std::fs::create_dir_all(config.issues_dir.join("bob")).unwrap();
1944        // The reserved fallback is not a person and is not an option.
1945        std::fs::create_dir_all(config.issues_dir.join("unassigned")).unwrap();
1946        config.teams.insert(
1947            "core".to_string(),
1948            crate::config::Team {
1949                members: vec!["carol".to_string()],
1950            },
1951        );
1952        let store = Store::open(&config).unwrap();
1953
1954        let labels: Vec<String> = assignee_options(&store, Some("zoe"))
1955            .into_iter()
1956            .map(|option| option.label)
1957            .collect();
1958
1959        assert_eq!(
1960            labels,
1961            vec!["alice", "bob", "carol", "zoe"],
1962            "folders first, then team members, then the current value"
1963        );
1964    }
1965}