use std::fmt::Write as _;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::bulk::{Selection, SelectionScope, SelectionSource};
use crate::commands::triage as cli_triage;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::storage::Store;
use crate::triage::inbox::{self, AgeBucket, InboxScope, QueueMetrics};
use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch};
use crate::ui::bulk::BulkSelection;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueRow {
pub issue: Issue,
pub age_days: Option<i64>,
pub last_decision: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageSummary {
pub total: usize,
pub awaiting: usize,
pub deferred: usize,
pub accepted: usize,
pub declined: usize,
pub duplicated: usize,
pub unknown_age: usize,
pub age: Vec<AgeBucket>,
}
impl TriageSummary {
#[must_use]
pub fn of(metrics: &QueueMetrics<'_>) -> Self {
Self {
total: metrics.total,
awaiting: metrics.awaiting,
deferred: metrics.deferred,
accepted: metrics.accepted,
declined: metrics.declined,
duplicated: metrics.duplicated,
unknown_age: metrics.unknown_age,
age: metrics.age_distribution.clone(),
}
}
#[must_use]
pub fn header(&self) -> String {
format!(
"inbox: {} entr{} ({} awaiting, {} deferred)",
self.total,
if self.total == 1 { "y" } else { "ies" },
self.awaiting,
self.deferred
)
}
#[must_use]
pub fn age_line(&self) -> String {
let cells: Vec<String> = self
.age
.iter()
.map(|bucket| format!("{} {}", bucket.label, bucket.count))
.collect();
format!("age: {}", cells.join(" · "))
}
#[must_use]
pub fn decisions_line(&self) -> String {
format!(
"decisions: {} accepted · {} declined · {} duplicated · {} unknown age",
self.accepted, self.declined, self.duplicated, self.unknown_age
)
}
}
#[must_use]
pub fn decision_summary(row: &QueueRow) -> String {
let mut out = format!(
"#{number} \"{title}\" — {status}",
number = row.issue.number,
title = row.issue.title,
status = row.issue.status.display(),
);
match &row.last_decision {
Some(last) => {
let _ = write!(out, " — last decision: {last}");
}
None => out.push_str(" — last decision: none"),
}
out
}
fn last_decision(issue: &Issue) -> Option<String> {
let record = crate::triage::history::last_decision(issue)?;
let mut out = format!("{} -> {}", record.kind.as_str(), record.result);
if let Some(reason) = &record.reason {
let _ = write!(out, " — {reason}");
}
let when = record.timestamp.get(..10).unwrap_or(&record.timestamp);
let _ = write!(out, " ({}, {})", record.actor, when);
Some(out)
}
fn scope_of(store: &Store) -> Result<InboxScope> {
inbox::scope(&store.config)?.ok_or_else(|| {
TixError::InvalidInput(
"the triage inbox needs a `[triage]` section in tix.toml, and this project has none"
.to_string(),
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionField {
Reason,
Actor,
Target,
DuplicateOf,
}
impl DecisionField {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Reason => "reason",
Self::Actor => "actor",
Self::Target => "to",
Self::DuplicateOf => "of",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModalScope {
One(u32),
Many(BulkSelection),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModalState {
Editing,
NeedsInput(String),
Preview(Box<TriagePlan>),
Confirming(Box<TriagePlan>),
Done(String),
Refused(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageModal {
pub kind: DecisionKind,
pub scope: ModalScope,
pub heading: String,
pub fields: Vec<DecisionField>,
pub cursor: usize,
pub editing: bool,
pub draft: String,
pub reason: String,
pub actor: String,
pub target: String,
pub duplicate_of: String,
pub timestamp: String,
pub state: ModalState,
pub note: Option<String>,
pub plan_stale: bool,
}
impl TriageModal {
#[must_use]
pub fn new(kind: DecisionKind, scope: ModalScope, heading: String) -> Self {
let fields = Self::fields_for(kind);
Self {
kind,
scope,
heading,
fields,
cursor: 0,
editing: false,
draft: String::new(),
reason: String::new(),
actor: String::new(),
target: String::new(),
duplicate_of: String::new(),
timestamp: crate::model::utc_now_rfc3339(),
state: ModalState::Editing,
note: None,
plan_stale: false,
}
}
#[must_use]
pub fn field(&self, index: usize) -> DecisionField {
self.fields
.get(index)
.copied()
.unwrap_or(DecisionField::Reason)
}
#[must_use]
pub fn field_value(&self, field: DecisionField) -> &str {
match field {
DecisionField::Reason => &self.reason,
DecisionField::Actor => &self.actor,
DecisionField::Target => &self.target,
DecisionField::DuplicateOf => &self.duplicate_of,
}
}
fn field_mut(&mut self, field: DecisionField) -> &mut String {
match field {
DecisionField::Reason => &mut self.reason,
DecisionField::Actor => &mut self.actor,
DecisionField::Target => &mut self.target,
DecisionField::DuplicateOf => &mut self.duplicate_of,
}
}
pub fn begin_edit(&mut self) {
let field = self.field(self.cursor);
self.draft = self.field_value(field).to_string();
self.editing = true;
}
pub fn push_char(&mut self, c: char) {
if self.editing {
self.draft.push(c);
}
}
pub fn pop_char(&mut self) {
if self.editing {
self.draft.pop();
}
}
pub fn commit_edit(&mut self) {
if !self.editing {
return;
}
let field = self.field(self.cursor);
let draft = std::mem::take(&mut self.draft);
*self.field_mut(field) = draft;
self.editing = false;
self.plan_stale = true;
}
pub fn abort_edit(&mut self) {
self.draft.clear();
self.editing = false;
}
pub fn move_cursor(&mut self, delta: isize) {
if self.fields.is_empty() {
return;
}
let len = self.fields.len() as isize;
let next = (self.cursor as isize + delta).rem_euclid(len);
self.cursor = next as usize;
}
pub fn cycle_kind(&mut self) {
self.kind = match self.kind {
DecisionKind::Accept => DecisionKind::Decline,
DecisionKind::Decline => DecisionKind::Duplicate,
DecisionKind::Duplicate => DecisionKind::Defer,
DecisionKind::Defer => DecisionKind::Accept,
};
self.fields = Self::fields_for(self.kind);
if self.cursor >= self.fields.len() {
self.cursor = self.fields.len() - 1;
}
self.state = ModalState::Editing;
self.plan_stale = true;
}
fn fields_for(kind: DecisionKind) -> Vec<DecisionField> {
let mut fields = vec![DecisionField::Reason, DecisionField::Actor];
match kind {
DecisionKind::Accept => fields.push(DecisionField::Target),
DecisionKind::Duplicate => fields.push(DecisionField::DuplicateOf),
DecisionKind::Decline | DecisionKind::Defer => {}
}
fields
}
#[must_use]
pub fn request(&self, store: &Store) -> TriageDecision {
let actor = if self.actor.trim().is_empty() {
crate::storage::actor_identity()
} else {
self.actor.trim().to_string()
};
let mut decision = TriageDecision::new(
self.kind,
self.timestamp.clone(),
actor,
self.reason.clone(),
);
if self.kind == DecisionKind::Accept {
let target = self.target.trim();
decision.target_status = if target.is_empty() {
None
} else {
Some(target.to_string())
};
if decision.target_status.is_none() {
decision.target_status = store
.config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone());
}
}
decision
}
#[must_use]
pub fn duplicate_target(&self) -> Option<String> {
let raw = self.duplicate_of.trim();
(!raw.is_empty() && self.kind == DecisionKind::Duplicate).then(|| raw.to_string())
}
pub fn plan(&self, store: &Store, issues: &[(u32, Issue)]) -> Result<TriagePlan> {
let selection = match &self.scope {
ModalScope::One(number) => Selection::from_issues(
[store.resolve_issue(&number.to_string())?],
SelectionSource::Explicit,
SelectionScope::Active,
),
ModalScope::Many(marked) => marked.to_selection(issues).0,
};
batch::build_plan(
store,
selection,
self.request(store),
self.duplicate_target().as_deref(),
)
}
pub fn preview(&mut self, store: &Store, issues: &[(u32, Issue)]) -> Result<()> {
if let ModalScope::Many(marked) = &self.scope {
if marked.is_empty() {
self.state =
ModalState::NeedsInput("press `space` on a card to mark it".to_string());
return Ok(());
}
let (_selection, dropped) = marked.to_selection(issues);
self.note = (!dropped.is_empty()).then(|| {
format!(
"{} marked issue(s) left the inbox and were left out: #{}",
dropped.len(),
dropped
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", #")
)
});
}
match self.plan(store, issues) {
Ok(plan) => {
self.state = ModalState::Preview(Box::new(plan));
self.plan_stale = false;
}
Err(e) => {
self.state = ModalState::Refused(e.to_string());
self.note = Some(e.to_string());
}
}
Ok(())
}
pub fn confirm(&mut self, store: &Store) -> Result<()> {
if self.plan_stale
&& matches!(
self.state,
ModalState::Preview(_) | ModalState::Confirming(_)
)
{
self.state = ModalState::Refused(
"the fields changed since the preview — press `p` again to see what would \
happen now"
.to_string(),
);
return Ok(());
}
match &self.state {
ModalState::Preview(plan) => {
self.state = ModalState::Confirming(plan.clone());
Ok(())
}
ModalState::Confirming(plan) => {
let mut plan = (**plan).clone();
plan.confirm();
match batch::apply(store, &plan) {
Ok(report) => {
let text = cli_triage::render_report(&report, &plan);
self.state = ModalState::Done(text);
self.plan_stale = false;
}
Err(e) => {
self.state = ModalState::Refused(e.to_string());
self.note = Some(e.to_string());
}
}
Ok(())
}
_ => Ok(()),
}
}
#[must_use]
pub fn is_armed(&self) -> bool {
matches!(self.state, ModalState::Confirming(_))
}
#[must_use]
pub fn state_label(&self) -> &'static str {
match &self.state {
ModalState::Editing => "editing",
ModalState::NeedsInput(_) => "needs input",
ModalState::Preview(_) | ModalState::Confirming(_) if self.plan_stale => {
"stale — preview again"
}
ModalState::Preview(_) => "preview — nothing written",
ModalState::Confirming(_) => "confirming — y writes",
ModalState::Done(_) => "done",
ModalState::Refused(_) => "refused",
}
}
#[must_use]
pub fn footer(&self) -> String {
if self.editing {
return "enter commit · esc revert".to_string();
}
if self.plan_stale
&& matches!(
self.state,
ModalState::Preview(_) | ModalState::Confirming(_)
)
{
return "press p to re-preview · esc to cancel".to_string();
}
match &self.state {
ModalState::Preview(_) => "y to confirm · p to re-preview · esc to cancel".to_string(),
ModalState::Confirming(_) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
ModalState::Done(_) | ModalState::Refused(_) | ModalState::NeedsInput(_) => {
"p to re-preview · esc to close".to_string()
}
ModalState::Editing => {
"a decision · j/k field · enter edit · t target · p preview · esc cancel"
.to_string()
}
}
}
#[must_use]
pub fn rows(&self) -> Vec<(bool, &'static str, String)> {
self.fields
.iter()
.enumerate()
.map(|(index, field)| {
let value = if self.editing && index == self.cursor {
format!("{}█", self.draft)
} else {
let value = self.field_value(*field);
if value.is_empty() {
"—".to_string()
} else {
value.to_string()
}
};
(index == self.cursor, field.label(), value)
})
.collect()
}
#[must_use]
pub fn body(&self) -> String {
match &self.state {
ModalState::Editing => {
let mut out = format!("decision: {}\n", self.kind);
if self.kind.requires_reason() {
out.push_str("a reason is required\n");
}
out.push_str("fields (enter to edit):\n");
for (on, name, value) in self.rows() {
let cursor = if on { ">" } else { " " };
out.push_str(&format!("{cursor} {name}: {value}\n"));
}
if let Some(note) = &self.note {
out.push_str(&format!("\nnote: {note}\n"));
}
out.push('\n');
out.push_str(&self.footer());
out
}
ModalState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
ModalState::Preview(plan) => {
let mut out = cli_triage::render_plan(plan);
if let Some(note) = &self.note {
out.push_str(&format!("\nnote: {note}\n"));
}
out.push('\n');
out.push_str(&self.footer());
out
}
ModalState::Confirming(plan) => {
let mut out = cli_triage::render_plan(plan);
out.push('\n');
out.push_str(&self.footer());
out
}
ModalState::Done(text) | ModalState::Refused(text) => text.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageState {
pub rows: Vec<QueueRow>,
pub summary: TriageSummary,
pub cursor: usize,
pub selection: BulkSelection,
pub by_age: bool,
pub modal: Option<TriageModal>,
pub note: Option<String>,
}
impl TriageState {
pub fn open(store: &Store) -> Result<Self> {
let mut state = Self {
rows: Vec::new(),
summary: TriageSummary {
total: 0,
awaiting: 0,
deferred: 0,
accepted: 0,
declined: 0,
duplicated: 0,
unknown_age: 0,
age: Vec::new(),
},
cursor: 0,
selection: BulkSelection::new(),
by_age: false,
modal: None,
note: None,
};
state.refresh(store)?;
Ok(state)
}
pub fn refresh(&mut self, store: &Store) -> Result<()> {
let scope = scope_of(store)?;
let corpus = crate::search::Corpus::build(store)?;
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
self.summary = TriageSummary::of(&metrics);
self.rows = metrics
.entries
.iter()
.map(|entry| QueueRow {
issue: (*entry.issue).clone(),
age_days: entry.age_days,
last_decision: last_decision(entry.issue),
})
.collect();
if self.by_age {
self.sort_by_age();
}
self.clamp_cursor();
Ok(())
}
fn sort_by_age(&mut self) {
self.rows.sort_by(|a, b| match (a.age_days, b.age_days) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
fn clamp_cursor(&mut self) {
if self.rows.is_empty() {
self.cursor = 0;
} else if self.cursor >= self.rows.len() {
self.cursor = self.rows.len() - 1;
}
}
pub fn move_cursor(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let len = self.rows.len() as isize;
let next = (self.cursor as isize + delta).rem_euclid(len);
self.cursor = next as usize;
}
#[must_use]
pub fn current(&self) -> Option<&QueueRow> {
self.rows.get(self.cursor)
}
pub fn toggle_current(&mut self) {
let Some(row) = self.rows.get(self.cursor) else {
return;
};
match self.selection.toggle(row.issue.number) {
Ok(_) => {}
Err(problem) => self.note = Some(problem.to_string()),
}
}
pub fn clear_selection(&mut self) {
self.selection.clear();
}
pub fn open_single(&mut self, kind: DecisionKind) -> bool {
let Some(row) = self.rows.get(self.cursor) else {
self.note = Some("the inbox is empty — there is nothing to decide".to_string());
return false;
};
let heading = format!("#{} {}", row.issue.number, row.issue.title);
self.modal = Some(TriageModal::new(
kind,
ModalScope::One(row.issue.number),
heading,
));
true
}
pub fn open_batch(&mut self, kind: DecisionKind) -> bool {
if self.selection.is_empty() {
self.note = Some("press `space` on a card to mark it first".to_string());
return false;
}
let heading = format!("{} marked issue(s)", self.selection.len());
self.modal = Some(TriageModal::new(
kind,
ModalScope::Many(self.selection.clone()),
heading,
));
true
}
pub fn close_modal(&mut self) {
self.modal = None;
}
fn issues(&self) -> Vec<(u32, Issue)> {
self.rows
.iter()
.map(|row| (row.issue.number, row.issue.clone()))
.collect()
}
pub fn preview(&mut self, store: &Store) -> Result<()> {
let issues = self.issues();
if let Some(modal) = self.modal.as_mut() {
modal.preview(store, &issues)?;
}
Ok(())
}
pub fn confirm(&mut self, store: &Store) -> Result<()> {
if let Some(modal) = self.modal.as_mut() {
modal.confirm(store)?;
}
Ok(())
}
#[must_use]
pub fn modal_finished(&self) -> bool {
matches!(
self.modal.as_ref().map(|modal| &modal.state),
Some(ModalState::Done(_))
)
}
#[must_use]
pub fn display_note(&self) -> Option<String> {
match self.modal.as_ref().map(|modal| &modal.state) {
Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => Some(text.clone()),
_ => self.note.clone(),
}
}
#[must_use]
pub fn footer(&self) -> String {
if self.selection.is_empty() {
"j/k move · space mark · a accept · d decline · u duplicate · f defer · p batch · A age · esc back"
.to_string()
} else {
format!(
"{} marked — p batch · X clear · a accept · d decline · u duplicate · f defer · esc back",
self.selection.len()
)
}
}
#[must_use]
pub fn body(&self) -> String {
let mut out = String::new();
let _ = writeln!(out, "{}", self.summary.header());
let _ = writeln!(out, "{}", self.summary.age_line());
let _ = writeln!(out, "{}", self.summary.decisions_line());
out.push('\n');
if self.rows.is_empty() {
out.push_str("the inbox is empty\n");
}
for (index, row) in self.rows.iter().enumerate() {
let cursor = if index == self.cursor { ">" } else { " " };
let mark = if self.selection.contains(row.issue.number) {
"*"
} else {
" "
};
let _ = writeln!(
out,
"{cursor}{mark}{:<4} {:<8} {}",
row.issue.number,
age_cell(row.age_days),
row.issue.title
);
}
if let Some(row) = self.current() {
let _ = writeln!(out, "\n{}", decision_summary(row));
}
if let Some(note) = self.display_note() {
let _ = writeln!(out, "\n{note}");
}
let _ = write!(out, "\n{}", self.footer());
out
}
}
fn age_cell(age: Option<i64>) -> String {
match age {
Some(0) => "today".to_string(),
Some(days) => format!("{days}d"),
None => "unknown".to_string(),
}
}
pub fn render(frame: &mut Frame, area: Rect, state: &TriageState) {
let rect = crate::ui::modal::modal_rect(area);
frame.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" triage inbox ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(rect);
frame.render_widget(block, rect);
let body = state.body();
frame.render_widget(Paragraph::new(body), inner);
if let Some(modal) = state.modal.as_ref() {
let modal_rect = crate::ui::modal::modal_rect(rect);
frame.render_widget(Clear, modal_rect);
let modal_block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} — {} ", modal.kind, modal.state_label()))
.border_style(Style::default().fg(Color::Magenta));
let modal_inner = modal_block.inner(modal_rect);
frame.render_widget(modal_block, modal_rect);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(modal_inner);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
modal.heading.clone(),
Style::default().add_modifier(Modifier::BOLD),
))),
chunks[0],
);
frame.render_widget(Paragraph::new(modal.body()), chunks[1]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triage_tui_fields_exist_only_for_the_decision_that_can_use_them() {
let one = ModalScope::One(1);
let accept = TriageModal::new(DecisionKind::Accept, one.clone(), "a".into());
assert_eq!(
accept.fields,
vec![
DecisionField::Reason,
DecisionField::Actor,
DecisionField::Target
]
);
let decline = TriageModal::new(DecisionKind::Decline, one.clone(), "d".into());
assert_eq!(
decline.fields,
vec![DecisionField::Reason, DecisionField::Actor],
"`--to` has nowhere to be typed on a decline, so it cannot be"
);
let duplicate = TriageModal::new(DecisionKind::Duplicate, one.clone(), "u".into());
assert_eq!(
duplicate.fields,
vec![
DecisionField::Reason,
DecisionField::Actor,
DecisionField::DuplicateOf
]
);
let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
assert_eq!(
defer.fields,
vec![DecisionField::Reason, DecisionField::Actor]
);
}
#[test]
fn triage_tui_the_cursor_only_moves_over_the_fields_the_decision_has() {
let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
modal.move_cursor(1);
assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
modal.move_cursor(1);
assert_eq!(
modal.field(modal.cursor),
DecisionField::Reason,
"two fields, so the third step wraps back to the first"
);
modal.move_cursor(-1);
assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
}
#[test]
fn triage_tui_a_cancelled_draft_never_reaches_the_row_it_was_typed_into() {
let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
modal.begin_edit();
for c in "half a reason".chars() {
modal.push_char(c);
}
modal.abort_edit();
assert!(!modal.editing);
assert_eq!(modal.reason, "", "the row never saw the draft");
}
#[test]
fn triage_tui_a_committed_draft_lands_on_the_row_under_the_cursor() {
let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
modal.begin_edit();
for c in "no budget".chars() {
modal.push_char(c);
}
modal.commit_edit();
assert_eq!(modal.reason, "no budget");
assert_eq!(modal.field_value(modal.field(0)), "no budget");
}
#[test]
fn triage_tui_an_accept_without_a_configured_target_names_none() {
let config = crate::config::Config::default();
let modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
let request = modal.request(&Store::open(&config).expect("an empty store opens"));
assert_eq!(
request.target_status, None,
"no `--to` and no `[triage].accepted_status` means the service \
refuses `missing_target`, exactly as the CLI does"
);
}
#[test]
fn triage_tui_the_header_is_the_cli_headers_wording() {
let summary = TriageSummary {
total: 3,
awaiting: 2,
deferred: 1,
accepted: 0,
declined: 0,
duplicated: 0,
unknown_age: 1,
age: Vec::new(),
};
assert_eq!(
summary.header(),
"inbox: 3 entries (2 awaiting, 1 deferred)"
);
}
#[test]
fn triage_tui_an_unknown_age_is_unknown_and_never_zero() {
assert_eq!(age_cell(None), "unknown");
assert_eq!(age_cell(Some(0)), "today");
assert_eq!(age_cell(Some(7)), "7d");
}
#[test]
fn triage_tui_the_footer_names_only_keys_that_mean_something_now() {
let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
assert!(modal.footer().contains("p preview"), "editing: preview");
modal.begin_edit();
assert!(
modal.footer().contains("enter commit"),
"a letter while editing must not be advertised as a command"
);
assert!(!modal.footer().contains("p preview"));
}
}