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};
46
47use crate::bulk::{Selection, SelectionScope, SelectionSource};
48use crate::commands::triage as cli_triage;
49use crate::error::{Result, TixError};
50use crate::model::Issue;
51use crate::storage::Store;
52use crate::triage::inbox::{self, AgeBucket, InboxScope, QueueMetrics};
53use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch};
54use crate::ui::bulk::BulkSelection;
55
56// ---------------------------------------------------------------------------
57// T029/T030 — the queue snapshot
58// ---------------------------------------------------------------------------
59
60/// One row of the inbox: the issue, how long it has waited, and the last
61/// decision recorded on it.
62///
63/// Owned rather than borrowed because [`inbox::QueueMetrics`] borrows the
64/// snapshot it was collected from, and the inbox outlives the query that
65/// filled it.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct QueueRow {
68    /// The issue as the snapshot read it.
69    pub issue: Issue,
70    /// Days since the entry stamp; `None` when nothing recorded one. Never
71    /// `0` for "unknown" — that is [`inbox`]'s rule and it is copied, not
72    /// re-derived.
73    pub age_days: Option<i64>,
74    /// The last triage decision, as `kind -> result — reason (actor, date)`,
75    /// or `None` for an issue nobody has decided.
76    pub last_decision: Option<String>,
77}
78
79/// The counts and histogram the header renders, copied out of [`QueueMetrics`]
80/// so the snapshot outlives the query that produced it.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct TriageSummary {
83    /// Inbox entries.
84    pub total: usize,
85    /// Entries in the entry column.
86    pub awaiting: usize,
87    /// Entries in the deferred column.
88    pub deferred: usize,
89    /// Entries already `completed` while still in an inbox column.
90    pub accepted: usize,
91    /// Entries already `canceled` while still in an inbox column.
92    pub declined: usize,
93    /// Entries that originate a `duplicates` edge.
94    pub duplicated: usize,
95    /// Entries with no readable entry stamp.
96    pub unknown_age: usize,
97    /// The age histogram, in [`inbox::AGE_BUCKETS`] order. Sums to `total`.
98    pub age: Vec<AgeBucket>,
99}
100
101impl TriageSummary {
102    /// Copy the metrics out of the query that borrowed them.
103    #[must_use]
104    pub fn of(metrics: &QueueMetrics<'_>) -> Self {
105        Self {
106            total: metrics.total,
107            awaiting: metrics.awaiting,
108            deferred: metrics.deferred,
109            accepted: metrics.accepted,
110            declined: metrics.declined,
111            duplicated: metrics.duplicated,
112            unknown_age: metrics.unknown_age,
113            age: metrics.age_distribution.clone(),
114        }
115    }
116
117    /// The header line, in the **CLI's** wording.
118    ///
119    /// `commands::triage::summary_line` prints this same string for
120    /// `tix triage list --count`, and
121    /// `triage_tui_the_inbox_counts_are_the_clis_counts_for_the_same_snapshot`
122    /// pins the two together: a header the two surfaces could spell differently
123    /// is a header a reader would trust differently.
124    #[must_use]
125    pub fn header(&self) -> String {
126        format!(
127            "inbox: {} entr{} ({} awaiting, {} deferred)",
128            self.total,
129            if self.total == 1 { "y" } else { "ies" },
130            self.awaiting,
131            self.deferred
132        )
133    }
134
135    /// The age histogram, every bucket named including the zeros.
136    #[must_use]
137    pub fn age_line(&self) -> String {
138        let cells: Vec<String> = self
139            .age
140            .iter()
141            .map(|bucket| format!("{} {}", bucket.label, bucket.count))
142            .collect();
143        format!("age: {}", cells.join(" · "))
144    }
145
146    /// The decision counts. `accepted`/`declined` are the WP02 *staleness*
147    /// readings — they come from `is_completed` and the category resolver, not
148    /// from a column name.
149    #[must_use]
150    pub fn decisions_line(&self) -> String {
151        format!(
152            "decisions: {} accepted · {} declined · {} duplicated · {} unknown age",
153            self.accepted, self.declined, self.duplicated, self.unknown_age
154        )
155    }
156}
157
158/// The one row under the cursor, for the detail line below the list.
159#[must_use]
160pub fn decision_summary(row: &QueueRow) -> String {
161    let mut out = format!(
162        "#{number} \"{title}\" — {status}",
163        number = row.issue.number,
164        title = row.issue.title,
165        status = row.issue.status.display(),
166    );
167    match &row.last_decision {
168        Some(last) => {
169            let _ = write!(out, " — last decision: {last}");
170        }
171        None => out.push_str(" — last decision: none"),
172    }
173    out
174}
175
176/// The last decision on `issue`, formatted for a queue row.
177///
178/// `triage_decided` and friends rather than the decision verb: the History
179/// event's own kind is what is on disk, and a summary that renamed it would be
180/// a second spelling of the same fact.
181fn last_decision(issue: &Issue) -> Option<String> {
182    let record = crate::triage::history::last_decision(issue)?;
183    let mut out = format!("{} -> {}", record.kind.as_str(), record.result);
184    if let Some(reason) = &record.reason {
185        let _ = write!(out, " — {reason}");
186    }
187    let when = record.timestamp.get(..10).unwrap_or(&record.timestamp);
188    let _ = write!(out, " ({}, {})", record.actor, when);
189    Some(out)
190}
191
192/// The inbox scope, or the one refusal every triage surface shares.
193///
194/// The message names the *inbox* rather than `tix triage`, because this is the
195/// TUI. What the contract makes load-bearing is the category and the fact that
196/// both name `[triage]` in `tix.toml`, and both are the crate's existing
197/// `invalid input` variant — no triage diagnostic in
198/// `crate::workflow::DiagnosticKind` (whose `ALL` stays at seven).
199fn scope_of(store: &Store) -> Result<InboxScope> {
200    inbox::scope(&store.config)?.ok_or_else(|| {
201        TixError::InvalidInput(
202            "the triage inbox needs a `[triage]` section in tix.toml, and this project has none"
203                .to_string(),
204        )
205    })
206}
207
208// ---------------------------------------------------------------------------
209// T031 — the decision modal
210// ---------------------------------------------------------------------------
211
212/// What the modal is asking for.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum DecisionField {
215    Reason,
216    Actor,
217    /// The accept target (`--to`). Present for `accept` only, so `--to` on a
218    /// decline cannot be typed — the same check `run_batch` makes on a flag.
219    Target,
220    /// The duplicate target (`--of`). Present for `duplicate` only, so `--of`
221    /// on an accept cannot be typed either.
222    DuplicateOf,
223}
224
225impl DecisionField {
226    #[must_use]
227    pub fn label(self) -> &'static str {
228        match self {
229            Self::Reason => "reason",
230            Self::Actor => "actor",
231            Self::Target => "to",
232            Self::DuplicateOf => "of",
233        }
234    }
235}
236
237/// The issue a decision is about: one from the cursor, or everything marked.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ModalScope {
240    /// One issue, by number. Resolved from the **store** at plan time, so the
241    /// plan is built from the file on disk and not from a row the list drew
242    /// before the file changed.
243    One(u32),
244    /// The marked issues, in the order they were marked.
245    Many(BulkSelection),
246}
247
248/// Where the modal is in its own cycle. The #173 batch modal's shape, because
249/// "I saw it" and "I approved it" are two decisions here too.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ModalState {
252    /// The fields, ready to edit.
253    Editing,
254    /// The modal asked for something it cannot plan without.
255    NeedsInput(String),
256    /// A plan is on screen. **Nothing has been written.**
257    Preview(Box<TriagePlan>),
258    /// The plan is armed: one more `y` writes.
259    Confirming(Box<TriagePlan>),
260    /// The apply ran. Its per-item outcomes and counts are in the string.
261    Done(String),
262    /// The plan or the apply was refused. Its reasons are in the string.
263    Refused(String),
264}
265
266/// The decision modal: four fields, four states, one plan.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct TriageModal {
269    /// Which decision.
270    pub kind: DecisionKind,
271    /// One issue or the marked set.
272    pub scope: ModalScope,
273    /// What the title says: `#12 Alpha` or `3 marked issues`.
274    pub heading: String,
275    /// The fields this decision has, in the order they are drawn.
276    pub fields: Vec<DecisionField>,
277    /// Which field the cursor is on.
278    pub cursor: usize,
279    /// Whether the cursor's field is being typed into.
280    pub editing: bool,
281    /// The text being typed. Discarded on `esc`, never committed.
282    pub draft: String,
283    pub reason: String,
284    pub actor: String,
285    pub target: String,
286    pub duplicate_of: String,
287    /// The decision timestamp, fixed when the modal opened so a preview and the
288    /// apply it authorises stamp the same event.
289    pub timestamp: String,
290    pub state: ModalState,
291    /// The last message, for the status bar and the body.
292    pub note: Option<String>,
293    /// Whether the plan no longer matches the screen.
294    pub plan_stale: bool,
295}
296
297impl TriageModal {
298    /// A fresh modal for `kind` over `scope`.
299    ///
300    /// The fields are fixed per decision rather than per keystroke, which is
301    /// how `--to` on a decline and `--of` on an accept become *untypeable*
302    /// instead of merely refused: the flag does not exist to type into.
303    #[must_use]
304    pub fn new(kind: DecisionKind, scope: ModalScope, heading: String) -> Self {
305        let fields = Self::fields_for(kind);
306        Self {
307            kind,
308            scope,
309            heading,
310            fields,
311            cursor: 0,
312            editing: false,
313            draft: String::new(),
314            reason: String::new(),
315            actor: String::new(),
316            target: String::new(),
317            duplicate_of: String::new(),
318            timestamp: crate::model::utc_now_rfc3339(),
319            state: ModalState::Editing,
320            note: None,
321            plan_stale: false,
322        }
323    }
324
325    #[must_use]
326    pub fn field(&self, index: usize) -> DecisionField {
327        self.fields
328            .get(index)
329            .copied()
330            .unwrap_or(DecisionField::Reason)
331    }
332
333    #[must_use]
334    pub fn field_value(&self, field: DecisionField) -> &str {
335        match field {
336            DecisionField::Reason => &self.reason,
337            DecisionField::Actor => &self.actor,
338            DecisionField::Target => &self.target,
339            DecisionField::DuplicateOf => &self.duplicate_of,
340        }
341    }
342
343    fn field_mut(&mut self, field: DecisionField) -> &mut String {
344        match field {
345            DecisionField::Reason => &mut self.reason,
346            DecisionField::Actor => &mut self.actor,
347            DecisionField::Target => &mut self.target,
348            DecisionField::DuplicateOf => &mut self.duplicate_of,
349        }
350    }
351
352    pub fn begin_edit(&mut self) {
353        let field = self.field(self.cursor);
354        self.draft = self.field_value(field).to_string();
355        self.editing = true;
356    }
357
358    pub fn push_char(&mut self, c: char) {
359        if self.editing {
360            self.draft.push(c);
361        }
362    }
363
364    pub fn pop_char(&mut self) {
365        if self.editing {
366            self.draft.pop();
367        }
368    }
369
370    pub fn commit_edit(&mut self) {
371        if !self.editing {
372            return;
373        }
374        let field = self.field(self.cursor);
375        let draft = std::mem::take(&mut self.draft);
376        *self.field_mut(field) = draft;
377        self.editing = false;
378        // The row changed, so the plan on screen no longer describes it. The
379        // state is deliberately **not** reset to `Editing`: the plan is left
380        // visible and labelled stale, because a preview that silently vanished
381        // the moment a field was edited leaves the user with no way to see
382        // what their edit invalidated (contract §4: any change requires a new
383        // preview — and `confirm` is what refuses the old one).
384        self.plan_stale = true;
385    }
386
387    /// Abandon the draft. The row keeps whatever `commit_edit` last stored.
388    pub fn abort_edit(&mut self) {
389        self.draft.clear();
390        self.editing = false;
391    }
392
393    pub fn move_cursor(&mut self, delta: isize) {
394        if self.fields.is_empty() {
395            return;
396        }
397        let len = self.fields.len() as isize;
398        let next = (self.cursor as isize + delta).rem_euclid(len);
399        self.cursor = next as usize;
400    }
401
402    /// What the batch modal's `a` does: pick the next decision.
403    ///
404    /// The typed fields survive, so a reason the user already wrote does not
405    /// have to be written again because they changed their mind about which
406    /// decision it belonged to. What does **not** survive is any plan built
407    /// for the old verb — `state` goes back to [`ModalState::Editing`] and
408    /// `plan_stale` is set, so the only way to see a plan is to preview this
409    /// one (contract §4: any change requires a new preview).
410    pub fn cycle_kind(&mut self) {
411        self.kind = match self.kind {
412            DecisionKind::Accept => DecisionKind::Decline,
413            DecisionKind::Decline => DecisionKind::Duplicate,
414            DecisionKind::Duplicate => DecisionKind::Defer,
415            DecisionKind::Defer => DecisionKind::Accept,
416        };
417        self.fields = Self::fields_for(self.kind);
418        if self.cursor >= self.fields.len() {
419            self.cursor = self.fields.len() - 1;
420        }
421        self.state = ModalState::Editing;
422        self.plan_stale = true;
423    }
424
425    /// The rows this decision has, in the order they are drawn.
426    ///
427    /// Fixed per decision rather than per keystroke: `--to` on a decline and
428    /// `--of` on an accept do not exist as flags, so here they do not exist as
429    /// rows either, and the check `run_batch` makes on those flags is
430    /// unreachable by construction.
431    fn fields_for(kind: DecisionKind) -> Vec<DecisionField> {
432        let mut fields = vec![DecisionField::Reason, DecisionField::Actor];
433        match kind {
434            DecisionKind::Accept => fields.push(DecisionField::Target),
435            DecisionKind::Duplicate => fields.push(DecisionField::DuplicateOf),
436            DecisionKind::Decline | DecisionKind::Defer => {}
437        }
438        fields
439    }
440
441    /// The decision request these fields describe, shaped exactly like
442    /// `commands::triage::run_batch`'s: a whitespace-only target is no target,
443    /// and an accept that named none of its own names the configured one so the
444    /// preview and the service cannot propose two destinations.
445    ///
446    /// A blank actor means *not specified*, so it falls back to the identity
447    /// the CLI also uses when `--actor` is absent — an audit entry with no
448    /// actor is worse than no entry (contract §Required reason and actor).
449    #[must_use]
450    pub fn request(&self, store: &Store) -> TriageDecision {
451        let actor = if self.actor.trim().is_empty() {
452            crate::storage::actor_identity()
453        } else {
454            self.actor.trim().to_string()
455        };
456        let mut decision = TriageDecision::new(
457            self.kind,
458            self.timestamp.clone(),
459            actor,
460            self.reason.clone(),
461        );
462        if self.kind == DecisionKind::Accept {
463            let target = self.target.trim();
464            decision.target_status = if target.is_empty() {
465                None
466            } else {
467                Some(target.to_string())
468            };
469            if decision.target_status.is_none() {
470                decision.target_status = store
471                    .config
472                    .triage
473                    .as_ref()
474                    .and_then(|triage| triage.accepted_status.clone());
475            }
476        }
477        decision
478    }
479
480    /// The `--of` this modal carries, for a duplicate only.
481    #[must_use]
482    pub fn duplicate_target(&self) -> Option<String> {
483        let raw = self.duplicate_of.trim();
484        (!raw.is_empty() && self.kind == DecisionKind::Duplicate).then(|| raw.to_string())
485    }
486
487    /// One read-only plan over the modal's scope. **Writes nothing.**
488    ///
489    /// Both the single issue and the marked set go through
490    /// [`batch::build_plan`], which is T031's "route single and batch
491    /// operations through WP03/WP04": one preview engine, one refusal
492    /// vocabulary, one per-item outcome shape, whatever the size of the batch.
493    ///
494    /// # Errors
495    ///
496    /// Propagates a *plan-level* failure (no `[triage]` section, an unresolvable
497    /// `--of`, a `--to` on a decision that has none). A per-item refusal is not
498    /// an error — it is a refused item in the plan, which is where a surface
499    /// that reports every outcome has to put it.
500    pub fn plan(&self, store: &Store, issues: &[(u32, Issue)]) -> Result<TriagePlan> {
501        let selection = match &self.scope {
502            ModalScope::One(number) => Selection::from_issues(
503                [store.resolve_issue(&number.to_string())?],
504                SelectionSource::Explicit,
505                SelectionScope::Active,
506            ),
507            ModalScope::Many(marked) => marked.to_selection(issues).0,
508        };
509        batch::build_plan(
510            store,
511            selection,
512            self.request(store),
513            self.duplicate_target().as_deref(),
514        )
515    }
516
517    /// Build the preview. A refusal the modal caused lands in `state`, not in
518    /// the `Result`: an `Err` out of here would reach the event loop, and the
519    /// only thing that could do with it is end the session (the rule WP04 froze
520    /// for `BulkModal::preview`, and the reason `tix triage batch` prints a
521    /// refusal and exits non-zero instead of erroring).
522    pub fn preview(&mut self, store: &Store, issues: &[(u32, Issue)]) -> Result<()> {
523        if let ModalScope::Many(marked) = &self.scope {
524            if marked.is_empty() {
525                self.state =
526                    ModalState::NeedsInput("press `space` on a card to mark it".to_string());
527                return Ok(());
528            }
529            // A marked number that has left the inbox between marking and
530            // previewing is left out and **named**, the same way
531            // `BulkModal::preview` reports a card that left the board.
532            let (_selection, dropped) = marked.to_selection(issues);
533            self.note = (!dropped.is_empty()).then(|| {
534                format!(
535                    "{} marked issue(s) left the inbox and were left out: #{}",
536                    dropped.len(),
537                    dropped
538                        .iter()
539                        .map(u32::to_string)
540                        .collect::<Vec<_>>()
541                        .join(", #")
542                )
543            });
544        }
545        match self.plan(store, issues) {
546            Ok(plan) => {
547                self.state = ModalState::Preview(Box::new(plan));
548                self.plan_stale = false;
549            }
550            Err(e) => {
551                self.state = ModalState::Refused(e.to_string());
552                self.note = Some(e.to_string());
553            }
554        }
555        Ok(())
556    }
557
558    /// `y`: from a preview, arm. From an armed preview, apply.
559    ///
560    /// Two presses because "I saw it" and "I approved it" are two decisions;
561    /// one `y` conflates them (WP07's rule, kept rather than reinvented).
562    ///
563    /// The **stale** check runs before either branch: a plan built before the
564    /// last field edit is a plan nobody read as it now stands, so the apply
565    /// refuses rather than asking the second `y` to mean something different
566    /// from what the first one did.
567    ///
568    /// # Errors
569    ///
570    /// Only an I/O failure behind the keyboard. A refusal lands in
571    /// [`ModalState::Refused`], because the only thing an `Err` out of here
572    /// could do is end the session.
573    pub fn confirm(&mut self, store: &Store) -> Result<()> {
574        if self.plan_stale
575            && matches!(
576                self.state,
577                ModalState::Preview(_) | ModalState::Confirming(_)
578            )
579        {
580            self.state = ModalState::Refused(
581                "the fields changed since the preview — press `p` again to see what would \
582                 happen now"
583                    .to_string(),
584            );
585            return Ok(());
586        }
587        match &self.state {
588            ModalState::Preview(plan) => {
589                self.state = ModalState::Confirming(plan.clone());
590                Ok(())
591            }
592            ModalState::Confirming(plan) => {
593                let mut plan = (**plan).clone();
594                plan.confirm();
595                match batch::apply(store, &plan) {
596                    Ok(report) => {
597                        let text = cli_triage::render_report(&report, &plan);
598                        self.state = ModalState::Done(text);
599                        self.plan_stale = false;
600                    }
601                    Err(e) => {
602                        self.state = ModalState::Refused(e.to_string());
603                        self.note = Some(e.to_string());
604                    }
605                }
606                Ok(())
607            }
608            _ => Ok(()),
609        }
610    }
611
612    #[must_use]
613    pub fn is_armed(&self) -> bool {
614        matches!(self.state, ModalState::Confirming(_))
615    }
616
617    #[must_use]
618    pub fn state_label(&self) -> &'static str {
619        match &self.state {
620            ModalState::Editing => "editing",
621            ModalState::NeedsInput(_) => "needs input",
622            ModalState::Preview(_) | ModalState::Confirming(_) if self.plan_stale => {
623                "stale — preview again"
624            }
625            ModalState::Preview(_) => "preview — nothing written",
626            ModalState::Confirming(_) => "confirming — y writes",
627            ModalState::Done(_) => "done",
628            ModalState::Refused(_) => "refused",
629        }
630    }
631
632    /// The footer: only the keys that mean something **right now**. A footer
633    /// that names `y` while `y` types a letter teaches the wrong thing.
634    #[must_use]
635    pub fn footer(&self) -> String {
636        if self.editing {
637            return "enter commit · esc revert".to_string();
638        }
639        if self.plan_stale
640            && matches!(
641                self.state,
642                ModalState::Preview(_) | ModalState::Confirming(_)
643            )
644        {
645            return "press p to re-preview · esc to cancel".to_string();
646        }
647        match &self.state {
648            ModalState::Preview(_) => "y to confirm · p to re-preview · esc to cancel".to_string(),
649            ModalState::Confirming(_) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
650            ModalState::Done(_) | ModalState::Refused(_) | ModalState::NeedsInput(_) => {
651                "p to re-preview · esc to close".to_string()
652            }
653            ModalState::Editing => {
654                "a decision · j/k field · enter edit · t target · p preview · esc cancel"
655                    .to_string()
656            }
657        }
658    }
659
660    /// The rows, as the modal draws them: a cursor, a label and a value.
661    #[must_use]
662    pub fn rows(&self) -> Vec<(bool, &'static str, String)> {
663        self.fields
664            .iter()
665            .enumerate()
666            .map(|(index, field)| {
667                let value = if self.editing && index == self.cursor {
668                    format!("{}█", self.draft)
669                } else {
670                    let value = self.field_value(*field);
671                    if value.is_empty() {
672                        "—".to_string()
673                    } else {
674                        value.to_string()
675                    }
676                };
677                (index == self.cursor, field.label(), value)
678            })
679            .collect()
680    }
681
682    /// The text a surface shows for the current state, from the **CLI's**
683    /// renderers wherever the CLI has one.
684    #[must_use]
685    pub fn body(&self) -> String {
686        match &self.state {
687            ModalState::Editing => {
688                let mut out = format!("decision: {}\n", self.kind);
689                if self.kind.requires_reason() {
690                    out.push_str("a reason is required\n");
691                }
692                out.push_str("fields (enter to edit):\n");
693                for (on, name, value) in self.rows() {
694                    let cursor = if on { ">" } else { " " };
695                    out.push_str(&format!("{cursor} {name}: {value}\n"));
696                }
697                if let Some(note) = &self.note {
698                    out.push_str(&format!("\nnote: {note}\n"));
699                }
700                out.push('\n');
701                out.push_str(&self.footer());
702                out
703            }
704            ModalState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
705            // The plan table, verbatim from the CLI: `render_plan` is what
706            // `tix triage batch --select …` prints for this same plan.
707            ModalState::Preview(plan) => {
708                let mut out = cli_triage::render_plan(plan);
709                if let Some(note) = &self.note {
710                    out.push_str(&format!("\nnote: {note}\n"));
711                }
712                out.push('\n');
713                out.push_str(&self.footer());
714                out
715            }
716            ModalState::Confirming(plan) => {
717                let mut out = cli_triage::render_plan(plan);
718                out.push('\n');
719                out.push_str(&self.footer());
720                out
721            }
722            ModalState::Done(text) | ModalState::Refused(text) => text.clone(),
723        }
724    }
725}
726
727// ---------------------------------------------------------------------------
728// T029 — the inbox
729// ---------------------------------------------------------------------------
730
731/// The inbox view's own state: a cursor and a selection that are **not** the
732/// board's, so opening and closing the inbox leaves
733/// `(active_col, selected)` exactly as it was (T029).
734#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct TriageState {
736    /// The queue, in the order the store lists it (or oldest-first under `A`).
737    pub rows: Vec<QueueRow>,
738    /// The header's counts.
739    pub summary: TriageSummary,
740    /// The row under the cursor.
741    pub cursor: usize,
742    /// The marked issues — #173's own ordered set, reused rather than grown
743    /// beside it (the "duplicate selection model" risk, mitigated).
744    pub selection: BulkSelection,
745    /// Oldest-first, the TUI's `--age`.
746    pub by_age: bool,
747    /// The decision modal, `Some` exactly while one is open.
748    pub modal: Option<TriageModal>,
749    /// The last message worth showing under the list.
750    pub note: Option<String>,
751}
752
753impl TriageState {
754    /// Query the queue and open the inbox.
755    ///
756    /// # Errors
757    ///
758    /// A project with no `[triage]` section has no inbox, and the refusal is
759    /// raised **before** the mode is entered — the board does not change under
760    /// a user who pressed `t` in a project that does not use triage.
761    pub fn open(store: &Store) -> Result<Self> {
762        let mut state = Self {
763            rows: Vec::new(),
764            summary: TriageSummary {
765                total: 0,
766                awaiting: 0,
767                deferred: 0,
768                accepted: 0,
769                declined: 0,
770                duplicated: 0,
771                unknown_age: 0,
772                age: Vec::new(),
773            },
774            cursor: 0,
775            selection: BulkSelection::new(),
776            by_age: false,
777            modal: None,
778            note: None,
779        };
780        state.refresh(store)?;
781        Ok(state)
782    }
783
784    /// Re-query the queue. Called on open and after every apply, so the counts
785    /// are never a stale snapshot of a file that has moved on.
786    ///
787    /// # Errors
788    ///
789    /// Propagates the scope refusal and any I/O failure.
790    pub fn refresh(&mut self, store: &Store) -> Result<()> {
791        // `scope_of` rather than `inbox::scope`, so the refusal this view shows
792        // is the one refusal there is — a project with no `[triage]` section
793        // has no inbox, and saying so in two different words would make the
794        // TUI and the CLI disagree about the same missing config.
795        let scope = scope_of(store)?;
796        let corpus = crate::search::Corpus::build(store)?;
797        // The same two calls `commands::triage::render_list` makes: one
798        // snapshot, one scope, one `today`, one pass.
799        let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
800        self.summary = TriageSummary::of(&metrics);
801        self.rows = metrics
802            .entries
803            .iter()
804            .map(|entry| QueueRow {
805                issue: (*entry.issue).clone(),
806                age_days: entry.age_days,
807                last_decision: last_decision(entry.issue),
808            })
809            .collect();
810        if self.by_age {
811            self.sort_by_age();
812        }
813        self.clamp_cursor();
814        Ok(())
815    }
816
817    /// Oldest first, with an unknown age sorted **last** on both ends rather
818    /// than coerced to `0` — the rule `commands::triage::render_list` gives
819    /// `--age`, restated rather than re-invented.
820    fn sort_by_age(&mut self) {
821        self.rows.sort_by(|a, b| match (a.age_days, b.age_days) {
822            (Some(x), Some(y)) => y.cmp(&x),
823            (Some(_), None) => std::cmp::Ordering::Less,
824            (None, Some(_)) => std::cmp::Ordering::Greater,
825            (None, None) => std::cmp::Ordering::Equal,
826        });
827    }
828
829    fn clamp_cursor(&mut self) {
830        if self.rows.is_empty() {
831            self.cursor = 0;
832        } else if self.cursor >= self.rows.len() {
833            self.cursor = self.rows.len() - 1;
834        }
835    }
836
837    pub fn move_cursor(&mut self, delta: isize) {
838        if self.rows.is_empty() {
839            return;
840        }
841        let len = self.rows.len() as isize;
842        let next = (self.cursor as isize + delta).rem_euclid(len);
843        self.cursor = next as usize;
844    }
845
846    #[must_use]
847    pub fn current(&self) -> Option<&QueueRow> {
848        self.rows.get(self.cursor)
849    }
850
851    /// Mark or unmark the row under the cursor. A refusal (a full selection, a
852    /// number already marked) is reported, not swallowed.
853    pub fn toggle_current(&mut self) {
854        let Some(row) = self.rows.get(self.cursor) else {
855            return;
856        };
857        match self.selection.toggle(row.issue.number) {
858            Ok(_) => {}
859            Err(problem) => self.note = Some(problem.to_string()),
860        }
861    }
862
863    pub fn clear_selection(&mut self) {
864        self.selection.clear();
865    }
866
867    /// Open the decision modal for the row under the cursor.
868    pub fn open_single(&mut self, kind: DecisionKind) -> bool {
869        let Some(row) = self.rows.get(self.cursor) else {
870            self.note = Some("the inbox is empty — there is nothing to decide".to_string());
871            return false;
872        };
873        let heading = format!("#{} {}", row.issue.number, row.issue.title);
874        self.modal = Some(TriageModal::new(
875            kind,
876            ModalScope::One(row.issue.number),
877            heading,
878        ));
879        true
880    }
881
882    /// Open the decision modal over everything marked.
883    pub fn open_batch(&mut self, kind: DecisionKind) -> bool {
884        if self.selection.is_empty() {
885            self.note = Some("press `space` on a card to mark it first".to_string());
886            return false;
887        }
888        let heading = format!("{} marked issue(s)", self.selection.len());
889        self.modal = Some(TriageModal::new(
890            kind,
891            ModalScope::Many(self.selection.clone()),
892            heading,
893        ));
894        true
895    }
896
897    /// Drop the modal and whatever state it was in. The queue and the
898    /// selection are untouched: a cancel changes nothing, which is T032's
899    /// claim about cancellation in full.
900    pub fn close_modal(&mut self) {
901        self.modal = None;
902    }
903
904    /// The snapshot the plan is built from, as the batch modal asks for it.
905    fn issues(&self) -> Vec<(u32, Issue)> {
906        self.rows
907            .iter()
908            .map(|row| (row.issue.number, row.issue.clone()))
909            .collect()
910    }
911
912    /// Preview the open decision. Writes nothing, in every branch.
913    ///
914    /// # Errors
915    /// Propagates an I/O failure behind the keyboard; a *refusal* is reported
916    /// through [`ModalState::Refused`].
917    pub fn preview(&mut self, store: &Store) -> Result<()> {
918        let issues = self.issues();
919        if let Some(modal) = self.modal.as_mut() {
920            modal.preview(store, &issues)?;
921        }
922        Ok(())
923    }
924
925    /// `y` on the open decision: arm, then apply.
926    ///
927    /// # Errors
928    /// Propagates an I/O failure behind the keyboard only.
929    pub fn confirm(&mut self, store: &Store) -> Result<()> {
930        // No `issues` here: the plan that is applied is the one the preview
931        // built and stored, re-checked by `batch::apply` for freshness — not a
932        // second plan rebuilt from a snapshot that may have moved on.
933        if let Some(modal) = self.modal.as_mut() {
934            modal.confirm(store)?;
935        }
936        Ok(())
937    }
938
939    /// Whether the modal finished, so the caller can refresh the queue and
940    /// report the result the way `BulkModal`'s handler does.
941    #[must_use]
942    pub fn modal_finished(&self) -> bool {
943        matches!(
944            self.modal.as_ref().map(|modal| &modal.state),
945            Some(ModalState::Done(_))
946        )
947    }
948
949    /// The note worth showing under the list, preferring a completed apply's
950    /// report over an older message.
951    #[must_use]
952    pub fn display_note(&self) -> Option<String> {
953        match self.modal.as_ref().map(|modal| &modal.state) {
954            Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => Some(text.clone()),
955            _ => self.note.clone(),
956        }
957    }
958
959    /// The inbox's own footer: what the list can do right now.
960    #[must_use]
961    pub fn footer(&self) -> String {
962        if self.selection.is_empty() {
963            "j/k move · space mark · a accept · d decline · u duplicate · f defer · p batch · A age · esc back"
964                .to_string()
965        } else {
966            format!(
967                "{} marked — p batch · X clear · a accept · d decline · u duplicate · f defer · esc back",
968                self.selection.len()
969            )
970        }
971    }
972
973    /// The text the inbox pane shows.
974    #[must_use]
975    pub fn body(&self) -> String {
976        let mut out = String::new();
977        let _ = writeln!(out, "{}", self.summary.header());
978        let _ = writeln!(out, "{}", self.summary.age_line());
979        let _ = writeln!(out, "{}", self.summary.decisions_line());
980        out.push('\n');
981        if self.rows.is_empty() {
982            out.push_str("the inbox is empty\n");
983        }
984        for (index, row) in self.rows.iter().enumerate() {
985            let cursor = if index == self.cursor { ">" } else { " " };
986            let mark = if self.selection.contains(row.issue.number) {
987                "*"
988            } else {
989                " "
990            };
991            let _ = writeln!(
992                out,
993                "{cursor}{mark}{:<4} {:<8} {}",
994                row.issue.number,
995                age_cell(row.age_days),
996                row.issue.title
997            );
998        }
999        if let Some(row) = self.current() {
1000            let _ = writeln!(out, "\n{}", decision_summary(row));
1001        }
1002        if let Some(note) = self.display_note() {
1003            let _ = writeln!(out, "\n{note}");
1004        }
1005        let _ = write!(out, "\n{}", self.footer());
1006        out
1007    }
1008}
1009
1010/// The age cell: a number of days, `today` for zero, `unknown` for no stamp.
1011///
1012/// Duplicated from `commands::triage::age_cell`, which is private to that
1013/// module: a reader who sees `0` believes the issue arrived today, and
1014/// `unknown` is the whole point of the entry stamp. The two spellings are
1015/// pinned together by
1016/// `triage_tui_the_inbox_counts_are_the_clis_counts_for_the_same_snapshot`,
1017/// which strips the inbox row's cursor and mark and compares the rest with
1018/// `tix triage list`'s own row — byte for byte, age cell included.
1019fn age_cell(age: Option<i64>) -> String {
1020    match age {
1021        Some(0) => "today".to_string(),
1022        Some(days) => format!("{days}d"),
1023        None => "unknown".to_string(),
1024    }
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Rendering
1029// ---------------------------------------------------------------------------
1030
1031/// Render the inbox (and the decision modal over it) on top of the board.
1032///
1033/// Never asserts on pixels — a test reads the buffer.
1034pub fn render(frame: &mut Frame, area: Rect, state: &TriageState) {
1035    let rect = crate::ui::modal::modal_rect(area);
1036    frame.render_widget(Clear, rect);
1037    let block = Block::default()
1038        .borders(Borders::ALL)
1039        .title(" triage inbox ")
1040        .border_style(Style::default().fg(Color::Cyan));
1041    let inner = block.inner(rect);
1042    frame.render_widget(block, rect);
1043
1044    let body = state.body();
1045    frame.render_widget(Paragraph::new(body), inner);
1046
1047    if let Some(modal) = state.modal.as_ref() {
1048        let modal_rect = crate::ui::modal::modal_rect(rect);
1049        frame.render_widget(Clear, modal_rect);
1050        let modal_block = Block::default()
1051            .borders(Borders::ALL)
1052            .title(format!(" {} — {} ", modal.kind, modal.state_label()))
1053            .border_style(Style::default().fg(Color::Magenta));
1054        let modal_inner = modal_block.inner(modal_rect);
1055        frame.render_widget(modal_block, modal_rect);
1056
1057        let chunks = Layout::default()
1058            .direction(Direction::Vertical)
1059            .constraints([Constraint::Length(1), Constraint::Min(1)])
1060            .split(modal_inner);
1061        frame.render_widget(
1062            Paragraph::new(Line::from(Span::styled(
1063                modal.heading.clone(),
1064                Style::default().add_modifier(Modifier::BOLD),
1065            ))),
1066            chunks[0],
1067        );
1068        frame.render_widget(Paragraph::new(modal.body()), chunks[1]);
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    /// Every WP06 unit test is named `triage_tui_*`, matching the gate
1077    /// `cargo test --test tui_state` and the naming the other triage work
1078    /// packages use.
1079    #[test]
1080    fn triage_tui_fields_exist_only_for_the_decision_that_can_use_them() {
1081        let one = ModalScope::One(1);
1082        let accept = TriageModal::new(DecisionKind::Accept, one.clone(), "a".into());
1083        assert_eq!(
1084            accept.fields,
1085            vec![
1086                DecisionField::Reason,
1087                DecisionField::Actor,
1088                DecisionField::Target
1089            ]
1090        );
1091        let decline = TriageModal::new(DecisionKind::Decline, one.clone(), "d".into());
1092        assert_eq!(
1093            decline.fields,
1094            vec![DecisionField::Reason, DecisionField::Actor],
1095            "`--to` has nowhere to be typed on a decline, so it cannot be"
1096        );
1097        let duplicate = TriageModal::new(DecisionKind::Duplicate, one.clone(), "u".into());
1098        assert_eq!(
1099            duplicate.fields,
1100            vec![
1101                DecisionField::Reason,
1102                DecisionField::Actor,
1103                DecisionField::DuplicateOf
1104            ]
1105        );
1106        let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
1107        assert_eq!(
1108            defer.fields,
1109            vec![DecisionField::Reason, DecisionField::Actor]
1110        );
1111    }
1112
1113    #[test]
1114    fn triage_tui_the_cursor_only_moves_over_the_fields_the_decision_has() {
1115        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1116        modal.move_cursor(1);
1117        assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1118        modal.move_cursor(1);
1119        assert_eq!(
1120            modal.field(modal.cursor),
1121            DecisionField::Reason,
1122            "two fields, so the third step wraps back to the first"
1123        );
1124        modal.move_cursor(-1);
1125        assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1126    }
1127
1128    #[test]
1129    fn triage_tui_a_cancelled_draft_never_reaches_the_row_it_was_typed_into() {
1130        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1131        modal.begin_edit();
1132        for c in "half a reason".chars() {
1133            modal.push_char(c);
1134        }
1135        modal.abort_edit();
1136        assert!(!modal.editing);
1137        assert_eq!(modal.reason, "", "the row never saw the draft");
1138    }
1139
1140    #[test]
1141    fn triage_tui_a_committed_draft_lands_on_the_row_under_the_cursor() {
1142        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1143        modal.begin_edit();
1144        for c in "no budget".chars() {
1145            modal.push_char(c);
1146        }
1147        modal.commit_edit();
1148        assert_eq!(modal.reason, "no budget");
1149        assert_eq!(modal.field_value(modal.field(0)), "no budget");
1150    }
1151
1152    #[test]
1153    fn triage_tui_an_accept_without_a_configured_target_names_none() {
1154        let config = crate::config::Config::default();
1155        let modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1156        let request = modal.request(&Store::open(&config).expect("an empty store opens"));
1157        assert_eq!(
1158            request.target_status, None,
1159            "no `--to` and no `[triage].accepted_status` means the service \
1160             refuses `missing_target`, exactly as the CLI does"
1161        );
1162    }
1163
1164    #[test]
1165    fn triage_tui_the_header_is_the_cli_headers_wording() {
1166        let summary = TriageSummary {
1167            total: 3,
1168            awaiting: 2,
1169            deferred: 1,
1170            accepted: 0,
1171            declined: 0,
1172            duplicated: 0,
1173            unknown_age: 1,
1174            age: Vec::new(),
1175        };
1176        assert_eq!(
1177            summary.header(),
1178            "inbox: 3 entries (2 awaiting, 1 deferred)"
1179        );
1180    }
1181
1182    #[test]
1183    fn triage_tui_an_unknown_age_is_unknown_and_never_zero() {
1184        assert_eq!(age_cell(None), "unknown");
1185        assert_eq!(age_cell(Some(0)), "today");
1186        assert_eq!(age_cell(Some(7)), "7d");
1187    }
1188
1189    #[test]
1190    fn triage_tui_the_footer_names_only_keys_that_mean_something_now() {
1191        let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1192        assert!(modal.footer().contains("p preview"), "editing: preview");
1193        modal.begin_edit();
1194        assert!(
1195            modal.footer().contains("enter commit"),
1196            "a letter while editing must not be advertised as a command"
1197        );
1198        assert!(!modal.footer().contains("p preview"));
1199    }
1200}