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 unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::app::{Action, App};
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;
use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueRow {
pub issue: Issue,
pub age_days: Option<i64>,
pub last_decision: crate::triage::history::LastDecision,
}
#[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 {
crate::triage::history::LastDecision::Readable(record) => {
let _ = write!(out, " — last decision: {}", format_decision(record));
}
crate::triage::history::LastDecision::Unreadable => {
out.push_str(" — last decision: unreadable");
}
crate::triage::history::LastDecision::None => {
out.push_str(" — last decision: none");
}
}
out
}
fn last_decision(issue: &Issue) -> crate::triage::history::LastDecision {
crate::triage::history::last_decision_state(issue)
}
fn format_decision(record: &crate::triage::history::TriageDecisionRecord) -> String {
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);
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,
Assignee,
DuplicateOf,
}
impl DecisionField {
#[must_use]
pub fn label(self, kind: DecisionKind) -> &'static str {
match (kind, self) {
(DecisionKind::Accept, Self::Target) => "column (required)",
(DecisionKind::Accept, Self::Actor) => "actor (required)",
(DecisionKind::Accept, Self::Reason) => "reason (optional)",
(DecisionKind::Accept, Self::Assignee) => "assignee (optional)",
(_, Self::Reason) => "reason (required)",
(_, Self::Actor) => "actor (required)",
(DecisionKind::Duplicate, Self::DuplicateOf) => "duplicate of (required)",
(_, Self::Target) => "column (required)",
(_, Self::Assignee) => "assignee (optional)",
(_, Self::DuplicateOf) => "duplicate of (required)",
}
}
}
#[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 ok_focused: bool,
pub editing: bool,
pub draft: String,
pub reason: String,
pub actor: String,
pub target: String,
pub assignee: 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,
ok_focused: true,
editing: false,
draft: String::new(),
reason: String::new(),
actor: String::new(),
target: String::new(),
assignee: 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::Assignee => &self.assignee,
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::Assignee => &mut self.assignee,
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.ok_focused = false;
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;
}
self.ok_focused = false;
let len = self.fields.len() as isize;
let next = (self.cursor as isize + delta).rem_euclid(len);
self.cursor = next as usize;
}
pub fn move_focus(&mut self, delta: isize) {
if self.fields.is_empty() {
self.ok_focused = true;
return;
}
if self.ok_focused {
self.ok_focused = false;
self.cursor = if delta < 0 { self.fields.len() - 1 } else { 0 };
return;
}
let next = self.cursor as isize + delta;
if next < 0 || next >= self.fields.len() as isize {
self.ok_focused = true;
} else {
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> {
match kind {
DecisionKind::Accept => vec![
DecisionField::Target,
DecisionField::Actor,
DecisionField::Reason,
DecisionField::Assignee,
],
DecisionKind::Duplicate => vec![
DecisionField::DuplicateOf,
DecisionField::Reason,
DecisionField::Actor,
],
DecisionKind::Decline | DecisionKind::Defer => {
vec![DecisionField::Reason, DecisionField::Actor]
}
}
}
pub fn prefill_defaults(&mut self, store: &Store, columns: &[String]) {
if self.kind == DecisionKind::Accept && self.target.trim().is_empty() {
if let Some(configured) = store
.config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone())
.filter(|target| !target.trim().is_empty())
{
self.target = configured;
} else if let Some(todo) = columns
.iter()
.find(|column| column.eq_ignore_ascii_case("todo"))
{
self.target.clone_from(todo);
}
}
if self.actor.trim().is_empty() {
self.actor = crate::storage::actor_identity();
}
}
pub fn choose_target(&mut self, target: String) {
self.target = target;
self.plan_stale = true;
}
#[must_use]
pub fn confirmation_question(&self) -> Option<String> {
let ModalScope::Many(marked) = &self.scope else {
return None;
};
Some(format!(
"Are you sure you want to apply {} to {} selected issues?",
self.kind,
marked.len()
))
}
#[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.assignee =
Some(self.assignee.trim().to_string()).filter(|assignee| !assignee.is_empty());
}
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 if self.ok_focused => {
"enter OK · tab fields · t target · p preview · esc cancel".to_string()
}
ModalState::Editing => {
"a decision · j/k field · enter edit/select · 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 mut 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()
}
};
if *field == DecisionField::Target {
value.push_str(" ▾");
}
(
!self.ok_focused && index == self.cursor,
field.label(self.kind),
value,
)
})
.collect()
}
#[must_use]
pub fn body(&self) -> String {
match &self.state {
ModalState::Editing => {
let mut out = format!("decision: {}\n", self.kind);
if let Some(question) = self.confirmation_question() {
out.push_str(&question);
out.push('\n');
}
out.push_str("fields (required first; 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 = String::new();
if let Some(question) = self.confirmation_question() {
out.push_str(&question);
out.push_str("\n\n");
}
out.push_str(&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 = String::new();
if let Some(question) = self.confirmation_question() {
out.push_str(&question);
out.push_str("\n\n");
}
out.push_str(&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, Copy, PartialEq, Eq)]
pub enum TriageDropdownKind {
Status,
DecisionTarget,
Assignee,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageDropdown {
pub kind: TriageDropdownKind,
pub state: DropdownState,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TriageControl {
#[default]
Queue,
Status,
Assignee,
New,
}
impl TriageControl {
pub const ALL: [Self; 4] = [Self::Queue, Self::Status, Self::Assignee, Self::New];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriageState {
pub rows: Vec<QueueRow>,
pub summary: TriageSummary,
pub cursor: usize,
pub focus: TriageControl,
pub selection: BulkSelection,
pub by_age: bool,
pub modal: Option<TriageModal>,
pub dropdown: Option<TriageDropdown>,
pub creating: bool,
pub create_buffer: String,
pub note: Option<String>,
}
#[must_use]
pub fn status_options(columns: &[String]) -> Vec<PickerItem> {
columns
.iter()
.map(|column| PickerItem {
id: column.clone(),
label: column.clone(),
})
.collect()
}
#[must_use]
pub fn assignee_options(store: &Store, current: Option<&str>) -> Vec<PickerItem> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
let mut push = |value: &str, out: &mut Vec<PickerItem>| {
let value = value.trim();
if value.is_empty() || !seen.insert(value.to_string()) {
return;
}
out.push(PickerItem {
id: value.to_string(),
label: value.to_string(),
});
};
if let Ok(folders) = store.assignee_segments() {
for folder in folders {
push(&folder, &mut out);
}
}
for team in store.config.teams.values() {
for member in &team.members {
push(member, &mut out);
}
}
if let Some(current) = current {
push(current, &mut out);
}
out
}
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,
focus: TriageControl::Queue,
selection: BulkSelection::new(),
by_age: false,
modal: None,
dropdown: None,
creating: false,
create_buffer: String::new(),
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;
}
pub fn move_focus(&mut self, delta: isize) {
let index = TriageControl::ALL
.iter()
.position(|control| *control == self.focus)
.unwrap_or_default() as isize;
let last = TriageControl::ALL.len() as isize - 1;
self.focus = TriageControl::ALL[(index + delta).clamp(0, last) as usize];
}
#[must_use]
pub fn current(&self) -> Option<&QueueRow> {
self.rows.get(self.cursor)
}
pub fn open_dropdown(
&mut self,
kind: TriageDropdownKind,
options: Vec<PickerItem>,
current: Option<&str>,
) {
let mut state = DropdownState::single(options);
if let Some(current) = current
&& let Some(index) = state.options.iter().position(|option| option.id == current)
&& let Some(visible) = state
.visible_indices()
.iter()
.position(|visible| *visible == index)
{
state.cursor = visible;
}
self.dropdown = Some(TriageDropdown { kind, state });
}
pub fn close_dropdown(&mut self) {
self.dropdown = None;
}
#[must_use]
pub fn handle_dropdown_key(
&mut self,
key: crossterm::event::KeyCode,
) -> Option<(TriageDropdownKind, String)> {
let dropdown = self.dropdown.as_mut()?;
match dropdown.state.handle_key(key) {
PickerEvent::Cancelled => {
self.dropdown = None;
None
}
PickerEvent::Selected(ids) => {
let kind = dropdown.kind;
let id = ids.first().cloned();
self.dropdown = None;
id.map(|id| (kind, id))
}
PickerEvent::Created(_) | PickerEvent::None => None,
}
}
pub fn toggle_current(&mut self) {
let Some(row) = self.rows.get(self.cursor) else {
return;
};
if self.selection.contains(row.issue.number) {
self.selection.remove(row.issue.number);
return;
}
match self.selection.toggle(row.issue.number) {
Ok(_) => {}
Err(problem) => self.note = Some(problem.to_string()),
}
}
pub fn open_single(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> 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);
let mut modal = TriageModal::new(kind, ModalScope::One(row.issue.number), heading);
modal.prefill_defaults(store, columns);
self.modal = Some(modal);
true
}
pub fn open_action(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
if self.selection.is_empty() {
self.open_single(kind, store, columns)
} else {
self.open_batch(kind, store, columns)
}
}
pub fn open_batch(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> 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());
let mut modal = TriageModal::new(kind, ModalScope::Many(self.selection.clone()), heading);
modal.prefill_defaults(store, columns);
self.modal = Some(modal);
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)?;
}
if self.modal_finished() {
let report = self.modal.as_ref().and_then(|modal| match &modal.state {
ModalState::Done(text) => Some(text.clone()),
_ => None,
});
self.refresh(store)?;
self.modal = None;
self.note = report;
}
Ok(())
}
pub fn modal_ok(&mut self, store: &Store) -> Result<()> {
match self.modal.as_ref().map(|modal| &modal.state) {
Some(ModalState::Editing | ModalState::NeedsInput(_) | ModalState::Refused(_)) => {
self.preview(store)?
}
Some(ModalState::Preview(_)) => self.confirm(store)?,
Some(ModalState::Confirming(_)) => self.confirm(store)?,
Some(ModalState::Done(_)) => self.close_modal(),
None => {}
}
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.creating {
return "enter create · esc cancel".to_string();
}
if self.selection.is_empty() {
"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"
.to_string()
} else {
format!(
"{} marked — space toggles · a/d/u/f apply to all · p batch accept · s status · @ assignee · esc back",
self.selection.len()
)
}
}
#[must_use]
pub fn body(&self) -> String {
self.body_with_width(usize::MAX)
}
fn body_with_width(&self, width: usize) -> 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 prefix = format!(
"{cursor}{mark}{:<4} {:<8} ",
row.issue.number,
age_cell(row.age_days),
);
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
let title_lines =
wrap_title(&row.issue.title, width.saturating_sub(prefix_width).max(1));
let indent = " ".repeat(prefix_width);
for (line_index, title_line) in title_lines.iter().enumerate() {
if line_index == 0 {
let _ = writeln!(out, "{prefix}{title_line}");
} else {
let _ = writeln!(out, "{indent}{title_line}");
}
}
}
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 wrap_title(title: &str, width: usize) -> Vec<String> {
if title.is_empty() || width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut line = String::new();
for word in title.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
let candidate_width =
UnicodeWidthStr::width(line.as_str()) + usize::from(!line.is_empty()) + word_width;
if !line.is_empty() && candidate_width > width {
lines.push(std::mem::take(&mut line));
}
if word_width <= width {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
} else {
let mut chunk = String::new();
for character in word.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if !chunk.is_empty()
&& UnicodeWidthStr::width(chunk.as_str()) + character_width > width
{
lines.push(std::mem::take(&mut chunk));
}
chunk.push(character);
}
line = chunk;
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
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(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" triage ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let labels = control_labels(state);
let mut spans: Vec<Span> = Vec::new();
for (index, (control, label)) in labels.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
label.clone(),
control_style(*control, state.focus),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
if state.modal.is_none() && !state.creating {
for (control, rect) in control_rects(area, state) {
app.record_region(rect, control_action(control));
}
}
if state.creating {
let line = Line::from(vec![
Span::styled(
"new issue title: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!("{}█", state.create_buffer)),
]);
frame.render_widget(Paragraph::new(line), chunks[1]);
return;
}
frame.render_widget(
Paragraph::new(state.body_with_width(usize::from(chunks[1].width))),
chunks[1],
);
if let Some(modal) = state.modal.as_ref() {
render_modal(app, frame, area, modal);
}
if state.dropdown.is_some() {
render_dropdown(app, frame, area, state);
}
}
fn control_labels(state: &TriageState) -> [(TriageControl, String); 4] {
let status = state
.current()
.map(|row| row.issue.status.display().to_string())
.unwrap_or_else(|| "—".to_string());
let assignee = state
.current()
.and_then(|row| row.issue.assignee.clone())
.unwrap_or_else(|| "unassigned".to_string());
[
(TriageControl::Queue, "queue".to_string()),
(TriageControl::Status, format!("[s] status: {status}")),
(TriageControl::Assignee, format!("[@] assignee: {assignee}")),
(TriageControl::New, "[n] new issue".to_string()),
]
}
fn control_style(control: TriageControl, focus: TriageControl) -> Style {
let color = match control {
TriageControl::Queue => Color::White,
TriageControl::Status => Color::Cyan,
TriageControl::Assignee => Color::Green,
TriageControl::New => Color::Yellow,
};
let style = Style::default().fg(color);
if control == focus {
style.add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
style
}
}
fn control_action(control: TriageControl) -> Action {
match control {
TriageControl::Queue => Action::TriageFocusQueue,
TriageControl::Status => Action::TriageOpenStatus,
TriageControl::Assignee => Action::TriageOpenAssignee,
TriageControl::New => Action::TriageCreate,
}
}
fn label_width(label: &str) -> u16 {
label.chars().count() as u16
}
#[must_use]
pub fn control_rects(area: Rect, state: &TriageState) -> [(TriageControl, Rect); 4] {
let inner = Block::default().borders(Borders::ALL).inner(area);
let mut x = inner.x;
control_labels(state).map(|(control, label)| {
let width = label_width(&label).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
let rect = Rect::new(x, inner.y, width, 1);
x = x.saturating_add(width).saturating_add(3);
(control, rect)
})
}
#[must_use]
pub fn dropdown_layout(area: Rect, state: &TriageState) -> Option<(Rect, Vec<Rect>)> {
let dropdown = state.dropdown.as_ref()?;
let visible = dropdown.state.visible_indices();
if visible.is_empty() {
return None;
}
let container = if dropdown.kind == TriageDropdownKind::DecisionTarget {
crate::ui::modal::modal_rect(area)
} else {
area
};
let inner = Block::default().borders(Borders::ALL).inner(container);
let inner = if dropdown.kind == TriageDropdownKind::DecisionTarget {
Rect {
y: inner.y.saturating_add(2),
height: inner.height.saturating_sub(2),
..inner
}
} else {
inner
};
let widest = visible
.iter()
.map(|index| label_width(&dropdown.state.options[*index].label))
.max()
.unwrap_or(0);
let height = (visible.len() as u16 + 2).min(inner.height.max(3));
if height < 3 || inner.width == 0 {
return None;
}
let title_width = label_width(dropdown_title(dropdown.kind)) + 2;
let width = (widest + 4).max(title_width).min(inner.width);
let rect = Rect::new(inner.x, inner.y, width, height);
let rows = (0..visible.len() as u16)
.take(height as usize - 2)
.map(|row| Rect::new(rect.x, rect.y + 1 + row, rect.width, 1))
.collect();
Some((rect, rows))
}
fn dropdown_title(kind: TriageDropdownKind) -> &'static str {
match kind {
TriageDropdownKind::Status => " choose status ",
TriageDropdownKind::DecisionTarget => " choose column ",
TriageDropdownKind::Assignee => " choose assignee ",
}
}
#[must_use]
pub fn modal_ok_rect(area: Rect) -> Rect {
let inner = Block::default()
.borders(Borders::ALL)
.inner(crate::ui::modal::modal_rect(area));
let width = 8.min(inner.width);
let x = inner.x + inner.width.saturating_sub(width) / 2;
Rect::new(
x,
inner
.y
.saturating_add(1)
.min(inner.bottom().saturating_sub(1)),
width,
1,
)
}
fn render_modal(app: &App, frame: &mut Frame, area: Rect, modal: &TriageModal) {
let modal_rect = crate::ui::modal::modal_rect(area);
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::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],
);
let ok = modal_ok_rect(area);
let style = if modal.ok_focused || app.is_hovered(&Action::TriageModalOk) {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
if ok.width > 0 && ok.height > 0 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled("[ OK ]", style))),
ok,
);
app.record_region(ok, Action::TriageModalOk);
}
frame.render_widget(Paragraph::new(modal.body()), chunks[2]);
}
fn render_dropdown(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
let Some(dropdown) = state.dropdown.as_ref() else {
return;
};
let Some((rect, rows)) = dropdown_layout(area, state) else {
return;
};
app.record_region(area, Action::TriageDropdownClose);
frame.render_widget(Clear, rect);
let title = dropdown_title(dropdown.kind);
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let visible = dropdown.state.visible_indices();
let lines: Vec<Line> = visible
.iter()
.enumerate()
.map(|(row, index)| {
let option = &dropdown.state.options[*index];
let cursor = if row == dropdown.state.cursor {
">"
} else {
" "
};
Line::from(format!("{cursor} {}", option.label))
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
for (row, row_rect) in rows.iter().enumerate() {
app.record_region(*row_rect, Action::TriagePickOption(row));
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
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::Target,
DecisionField::Actor,
DecisionField::Reason,
DecisionField::Assignee,
]
);
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::DuplicateOf,
DecisionField::Reason,
DecisionField::Actor,
]
);
let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
assert_eq!(
defer.fields,
vec![DecisionField::Reason, DecisionField::Actor]
);
assert_eq!(
DecisionField::Target.label(DecisionKind::Accept),
"column (required)"
);
assert_eq!(
DecisionField::Actor.label(DecisionKind::Accept),
"actor (required)"
);
assert_eq!(
DecisionField::Reason.label(DecisionKind::Accept),
"reason (optional)"
);
assert_eq!(
DecisionField::Assignee.label(DecisionKind::Accept),
"assignee (optional)"
);
}
#[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_ok_is_in_the_keyboard_focus_ring() {
let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
assert!(modal.ok_focused);
modal.move_focus(1);
assert!(!modal.ok_focused);
assert_eq!(modal.field(modal.cursor), DecisionField::Target);
modal.move_focus(-1);
assert!(modal.ok_focused);
modal.move_focus(-1);
assert!(!modal.ok_focused);
assert_eq!(modal.field(modal.cursor), DecisionField::Assignee);
modal.move_focus(1);
assert!(modal.ok_focused);
}
#[test]
fn triage_tui_accept_prefills_todo_and_the_actor_without_configuration() {
let config = crate::config::Config::default();
let store = Store::open(&config).expect("an empty store opens");
let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
let columns = vec!["triage".to_string(), "todo".to_string()];
modal.prefill_defaults(&store, &columns);
assert_eq!(modal.target, "todo");
assert_eq!(modal.actor, crate::storage::actor_identity());
}
#[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_a_long_unbroken_title_is_split_to_the_title_width() {
let lines = wrap_title("alpha-beta-gamma-delta", 10);
assert_eq!(lines, vec!["alpha-beta", "-gamma-del", "ta"]);
assert!(
lines
.iter()
.all(|line| UnicodeWidthStr::width(line.as_str()) <= 10)
);
}
#[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"));
}
#[test]
fn triage_tui_status_options_are_the_board_columns_in_order() {
let columns = vec!["triage".to_string(), "todo".to_string(), "done".to_string()];
let labels: Vec<String> = status_options(&columns)
.into_iter()
.map(|option| option.label)
.collect();
assert_eq!(labels, vec!["triage", "todo", "done"]);
}
#[test]
fn triage_tui_assignee_options_are_folders_teams_and_current() {
let tmp = tempfile::TempDir::new().unwrap();
let mut config = crate::config::Config {
issues_dir: tmp.path().to_path_buf(),
..crate::config::Config::default()
};
std::fs::create_dir_all(config.issues_dir.join("alice")).unwrap();
std::fs::create_dir_all(config.issues_dir.join("bob")).unwrap();
std::fs::create_dir_all(config.issues_dir.join("unassigned")).unwrap();
config.teams.insert(
"core".to_string(),
crate::config::Team {
members: vec!["carol".to_string()],
},
);
let store = Store::open(&config).unwrap();
let labels: Vec<String> = assignee_options(&store, Some("zoe"))
.into_iter()
.map(|option| option.label)
.collect();
assert_eq!(
labels,
vec!["alice", "bob", "carol", "zoe"],
"folders first, then team members, then the current value"
);
}
}